fixtureqa 0.4.33__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 (105) hide show
  1. {fixtureqa-0.4.33/fixtureqa.egg-info → fixtureqa-0.6.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/app.py +2 -1
  3. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/deps.py +5 -1
  4. fixtureqa-0.6.0/fixture/api/routers/scenarios.py +165 -0
  5. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/schemas.py +19 -0
  6. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_payload.py +10 -41
  7. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/scenario_runner.py +219 -64
  8. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/scenario_store.py +27 -0
  9. fixtureqa-0.6.0/fixture/core/value_gen.py +100 -0
  10. fixtureqa-0.6.0/fixture/static/assets/index-C46Em7NG.js +102 -0
  11. fixtureqa-0.4.33/fixture/static/assets/index-Ycej0J6b.css → fixtureqa-0.6.0/fixture/static/assets/index-DKAYJxvL.css +1 -1
  12. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/index.html +2 -2
  13. {fixtureqa-0.4.33 → fixtureqa-0.6.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  14. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
  15. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/pyproject.toml +1 -1
  16. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_scenarios.py +155 -0
  17. fixtureqa-0.6.0/tests/test_value_gen.py +45 -0
  18. fixtureqa-0.4.33/fixture/api/routers/scenarios.py +0 -79
  19. fixtureqa-0.4.33/fixture/static/assets/index-C7XDCotT.js +0 -102
  20. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/LICENSE +0 -0
  21. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/README.md +0 -0
  22. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/__init__.py +0 -0
  23. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/__main__.py +0 -0
  24. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/__init__.py +0 -0
  25. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/connection_manager.py +0 -0
  26. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/__init__.py +0 -0
  27. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/admin.py +0 -0
  28. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/auth.py +0 -0
  29. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/branding.py +0 -0
  30. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/custom_tags.py +0 -0
  31. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/fix_spec.py +0 -0
  32. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/messages.py +0 -0
  33. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/perf.py +0 -0
  34. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/sessions.py +0 -0
  35. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/setup.py +0 -0
  36. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/spec_overlay.py +0 -0
  37. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/templates.py +0 -0
  38. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/ws.py +0 -0
  39. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/config/__init__.py +0 -0
  40. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/__init__.py +0 -0
  41. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/atomic_io.py +0 -0
  42. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/auth.py +0 -0
  43. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/config_store.py +0 -0
  44. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/custom_tag_store.py +0 -0
  45. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/db_migrations.py +0 -0
  46. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/events.py +0 -0
  47. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_application.py +0 -0
  48. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_builder.py +0 -0
  49. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_parser.py +0 -0
  50. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_spec_parser.py +0 -0
  51. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_tags.py +0 -0
  52. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_time.py +0 -0
  53. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/housekeeping.py +0 -0
  54. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/inbound.py +0 -0
  55. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/json_store.py +0 -0
  56. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/latency_csv_writer.py +0 -0
  57. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/message_log.py +0 -0
  58. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/message_store.py +0 -0
  59. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/models.py +0 -0
  60. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_charts.py +0 -0
  61. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_engine.py +0 -0
  62. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_models.py +0 -0
  63. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_stats.py +0 -0
  64. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_store.py +0 -0
  65. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_writer.py +0 -0
  66. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/session.py +0 -0
  67. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/session_manager.py +0 -0
  68. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/spec_overlay_store.py +0 -0
  69. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/template_store.py +0 -0
  70. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/user_store.py +0 -0
  71. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/venue_responses.py +0 -0
  72. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/fix_specs/FIX42.xml +0 -0
  73. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/fix_specs/FIX44.xml +0 -0
  74. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/server.py +0 -0
  75. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  76. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  77. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  78. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/favicon.svg +0 -0
  79. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/ui/__init__.py +0 -0
  80. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  81. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  82. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/requires.txt +0 -0
  83. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/top_level.txt +0 -0
  84. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/setup.cfg +0 -0
  85. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_atomic_io.py +0 -0
  86. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_auth.py +0 -0
  87. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_config_store.py +0 -0
  88. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_connection_manager.py +0 -0
  89. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_db_migrations.py +0 -0
  90. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_fix_builder.py +0 -0
  91. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_health.py +0 -0
  92. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_inbound.py +0 -0
  93. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_inbound_validation.py +0 -0
  94. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_message_store.py +0 -0
  95. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_api.py +0 -0
  96. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_engine.py +0 -0
  97. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_models.py +0 -0
  98. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_payload.py +0 -0
  99. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_rehydrate.py +0 -0
  100. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_auto_response.py +0 -0
  101. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_lifecycle.py +0 -0
  102. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_manager_concurrency.py +0 -0
  103. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_sessions.py +0 -0
  104. {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_templates.py +0 -0
  105. {fixtureqa-0.4.33 → 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.4.33
3
+ Version: 0.6.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
9
9
  from ..core.custom_tag_store import CustomTagStore
10
10
  from ..core.housekeeping import HousekeepingService
11
11
  from ..core.session_manager import SessionManager
12
- from ..core.scenario_store import ScenarioStore
12
+ from ..core.scenario_store import ScenarioStore, ScenarioGroupStore
13
13
  from ..core.scenario_runner import ScenarioRunner
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
15
15
  from ..core.template_store import TemplateStore
@@ -86,6 +86,7 @@ def create_app(
86
86
  )
87
87
  app.state.template_store = TemplateStore(data_dir)
88
88
  app.state.scenario_store = ScenarioStore(data_dir)
89
+ app.state.scenario_group_store = ScenarioGroupStore(data_dir)
89
90
  app.state.custom_tag_store = CustomTagStore(data_dir)
90
91
  app.state.spec_overlay_store = SpecOverlayStore(data_dir)
91
92
  perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
@@ -8,7 +8,7 @@ from ..core.user_store import User, UserStore
8
8
  from ..core.auth import decode_token
9
9
  from ..core.housekeeping import HousekeepingService
10
10
  from ..core.template_store import TemplateStore
11
- from ..core.scenario_store import ScenarioStore
11
+ from ..core.scenario_store import ScenarioStore, ScenarioGroupStore
12
12
  from ..core.scenario_runner import ScenarioRunner
13
13
  from ..core.custom_tag_store import CustomTagStore
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
@@ -88,6 +88,10 @@ def get_scenario_store(request: HTTPConnection) -> ScenarioStore:
88
88
  return request.app.state.scenario_store
89
89
 
90
90
 
91
+ def get_scenario_group_store(request: HTTPConnection) -> ScenarioGroupStore:
92
+ return request.app.state.scenario_group_store
93
+
94
+
91
95
  def get_scenario_runner(request: HTTPConnection) -> ScenarioRunner:
92
96
  return request.app.state.scenario_runner
93
97
 
@@ -0,0 +1,165 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
2
+ from typing import Annotated
3
+
4
+ from ...core.scenario_store import ScenarioStore, ScenarioGroupStore
5
+ from ...core.scenario_runner import ScenarioRunner
6
+ from ...core.session_manager import SessionManager
7
+ from ...core.user_store import User
8
+ from ..deps import (get_current_user, get_scenario_store, get_scenario_runner,
9
+ get_scenario_group_store, get_session_manager)
10
+ from ..schemas import (ScenarioRequest, ScenarioResponse, ScenarioGroupRequest,
11
+ ScenarioGroupResponse, GroupRunRequest)
12
+
13
+ router = APIRouter(tags=["scenarios"])
14
+
15
+ SS = Depends(get_scenario_store)
16
+ SGS = Depends(get_scenario_group_store)
17
+ SR = Depends(get_scenario_runner)
18
+ SM = Depends(get_session_manager)
19
+ CurrentUser = Depends(get_current_user)
20
+
21
+
22
+ def _require_session_owned(sm: SessionManager, session_id: str, user: User) -> None:
23
+ """A scenario's session_id is stored data, not a path param — every session
24
+ a scenario (or group of scenarios) drives must be owned by the caller, or a
25
+ user could drive someone else's session through a scenario."""
26
+ cfg = sm.get_config(session_id)
27
+ if cfg is None:
28
+ raise HTTPException(status_code=404, detail=f"Session {session_id!r} not found")
29
+ if not user.is_admin and cfg.owner_uid != user.uid:
30
+ raise HTTPException(status_code=403, detail="Access denied")
31
+
32
+
33
+ @router.get("", response_model=list[ScenarioResponse])
34
+ def list_scenarios(store: ScenarioStore = SS, user: User = CurrentUser):
35
+ return store.list_scenarios(user.uid)
36
+
37
+
38
+ @router.post("", response_model=ScenarioResponse, status_code=status.HTTP_201_CREATED)
39
+ def create_scenario(body: ScenarioRequest, store: ScenarioStore = SS, user: User = CurrentUser):
40
+ data = body.model_dump()
41
+ return store.create_scenario(user.uid, data)
42
+
43
+
44
+ @router.put("/{scenario_id}", response_model=ScenarioResponse)
45
+ def update_scenario(
46
+ scenario_id: str,
47
+ body: ScenarioRequest,
48
+ store: ScenarioStore = SS,
49
+ user: User = CurrentUser,
50
+ ):
51
+ updated = store.update_scenario(user.uid, scenario_id, body.model_dump())
52
+ if updated is None:
53
+ raise HTTPException(status_code=404, detail="Scenario not found")
54
+ return updated
55
+
56
+
57
+ @router.delete("/{scenario_id}", status_code=status.HTTP_204_NO_CONTENT)
58
+ def delete_scenario(scenario_id: str, store: ScenarioStore = SS, user: User = CurrentUser):
59
+ if not store.delete_scenario(user.uid, scenario_id):
60
+ raise HTTPException(status_code=404, detail="Scenario not found")
61
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
62
+
63
+
64
+ # --- Suites (saved groups) + coordinated group runs -------------------------
65
+ # Declared before the parameterised "/{scenario_id}/..." routes so that, e.g.,
66
+ # POST "/groups/run" isn't captured by "/{scenario_id}/run".
67
+
68
+ @router.get("/groups", response_model=list[ScenarioGroupResponse])
69
+ def list_groups(store: ScenarioGroupStore = SGS, user: User = CurrentUser):
70
+ return store.list_groups(user.uid)
71
+
72
+
73
+ @router.post("/groups", response_model=ScenarioGroupResponse,
74
+ status_code=status.HTTP_201_CREATED)
75
+ def create_group(body: ScenarioGroupRequest, store: ScenarioGroupStore = SGS,
76
+ user: User = CurrentUser):
77
+ return store.create_group(user.uid, body.model_dump())
78
+
79
+
80
+ @router.put("/groups/{group_id}", response_model=ScenarioGroupResponse)
81
+ def update_group(group_id: str, body: ScenarioGroupRequest,
82
+ store: ScenarioGroupStore = SGS, user: User = CurrentUser):
83
+ updated = store.update_group(user.uid, group_id, body.model_dump())
84
+ if updated is None:
85
+ raise HTTPException(status_code=404, detail="Suite not found")
86
+ return updated
87
+
88
+
89
+ @router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
90
+ def delete_group(group_id: str, store: ScenarioGroupStore = SGS,
91
+ user: User = CurrentUser):
92
+ if not store.delete_group(user.uid, group_id):
93
+ raise HTTPException(status_code=404, detail="Suite not found")
94
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
95
+
96
+
97
+ def _launch_group(scenario_ids: list[str], store: ScenarioStore,
98
+ runner: ScenarioRunner, sm: SessionManager, user: User) -> dict:
99
+ if not scenario_ids:
100
+ raise HTTPException(status_code=400, detail="No scenarios to run")
101
+ scenarios: list[dict] = []
102
+ for sid in scenario_ids:
103
+ scenario = store.get_scenario(user.uid, sid)
104
+ if scenario is None:
105
+ raise HTTPException(status_code=404, detail=f"Scenario {sid!r} not found")
106
+ _require_session_owned(sm, scenario.get("session_id", ""), user)
107
+ scenarios.append(scenario)
108
+ group_run_id, run_ids = runner.run_group(scenarios, user.uid)
109
+ return {"group_run_id": group_run_id, "run_ids": run_ids}
110
+
111
+
112
+ @router.post("/groups/run")
113
+ def run_group_adhoc(body: GroupRunRequest, store: ScenarioStore = SS,
114
+ runner: ScenarioRunner = SR, sm: SessionManager = SM,
115
+ user: User = CurrentUser):
116
+ """Launch an ad-hoc set of scenarios as one coordinated group."""
117
+ return _launch_group(body.scenario_ids, store, runner, sm, user)
118
+
119
+
120
+ @router.post("/groups/{group_id}/run")
121
+ def run_group_saved(group_id: str, store: ScenarioStore = SS,
122
+ group_store: ScenarioGroupStore = SGS, runner: ScenarioRunner = SR,
123
+ sm: SessionManager = SM, user: User = CurrentUser):
124
+ """Launch a saved suite as one coordinated group."""
125
+ group = group_store.get_group(user.uid, group_id)
126
+ if group is None:
127
+ raise HTTPException(status_code=404, detail="Suite not found")
128
+ return _launch_group(group.get("scenario_ids", []), store, runner, sm, user)
129
+
130
+
131
+ @router.delete("/groups/runs/{group_run_id}", status_code=status.HTTP_204_NO_CONTENT)
132
+ def abort_group_run(group_run_id: str, runner: ScenarioRunner = SR,
133
+ user: User = CurrentUser):
134
+ try:
135
+ runner.abort_group(group_run_id, uid=user.uid, is_admin=user.is_admin)
136
+ except PermissionError:
137
+ raise HTTPException(status_code=403, detail="Access denied")
138
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
139
+
140
+
141
+ # --- Single scenario run ----------------------------------------------------
142
+
143
+ @router.post("/{scenario_id}/run")
144
+ def run_scenario(
145
+ scenario_id: str,
146
+ store: ScenarioStore = SS,
147
+ runner: ScenarioRunner = SR,
148
+ sm: SessionManager = SM,
149
+ user: User = CurrentUser,
150
+ ):
151
+ scenario = store.get_scenario(user.uid, scenario_id)
152
+ if scenario is None:
153
+ raise HTTPException(status_code=404, detail="Scenario not found")
154
+ _require_session_owned(sm, scenario.get("session_id", ""), user)
155
+ run_id = runner.run(scenario, user.uid)
156
+ return {"run_id": run_id}
157
+
158
+
159
+ @router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
160
+ def abort_run(run_id: str, runner: ScenarioRunner = SR, user: User = CurrentUser):
161
+ try:
162
+ runner.abort(run_id, uid=user.uid, is_admin=user.is_admin)
163
+ except PermissionError:
164
+ raise HTTPException(status_code=403, detail="Access denied")
165
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -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,23 @@ class ScenarioResponse(BaseModel):
279
281
  name: str
280
282
  session_id: str
281
283
  steps: list[ScenarioStepDef]
284
+ repeat: int = 1
285
+
286
+
287
+ class ScenarioGroupRequest(BaseModel):
288
+ name: str
289
+ scenario_ids: list[str] = []
290
+
291
+
292
+ class ScenarioGroupResponse(BaseModel):
293
+ id: str
294
+ name: str
295
+ scenario_ids: list[str] = []
296
+
297
+
298
+ class GroupRunRequest(BaseModel):
299
+ """Ad-hoc launch of several scenarios as one coordinated group."""
300
+ scenario_ids: list[str]
282
301
 
283
302
 
284
303
  class TemplateRequest(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: