fixtureqa 0.4.33__tar.gz → 0.5.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.4.33/fixtureqa.egg-info → fixtureqa-0.5.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/app.py +2 -1
  3. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/deps.py +5 -1
  4. fixtureqa-0.5.0/fixture/api/routers/scenarios.py +165 -0
  5. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/schemas.py +16 -0
  6. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/scenario_runner.py +122 -9
  7. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/scenario_store.py +27 -0
  8. fixtureqa-0.4.33/fixture/static/assets/index-Ycej0J6b.css → fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css +1 -1
  9. fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js +102 -0
  10. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/index.html +2 -2
  11. {fixtureqa-0.4.33 → fixtureqa-0.5.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  12. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/SOURCES.txt +2 -2
  13. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/pyproject.toml +1 -1
  14. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_scenarios.py +78 -0
  15. fixtureqa-0.4.33/fixture/api/routers/scenarios.py +0 -79
  16. fixtureqa-0.4.33/fixture/static/assets/index-C7XDCotT.js +0 -102
  17. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/LICENSE +0 -0
  18. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/README.md +0 -0
  19. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/__init__.py +0 -0
  20. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/__main__.py +0 -0
  21. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/__init__.py +0 -0
  22. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/connection_manager.py +0 -0
  23. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/__init__.py +0 -0
  24. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/admin.py +0 -0
  25. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/auth.py +0 -0
  26. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/branding.py +0 -0
  27. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/custom_tags.py +0 -0
  28. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/fix_spec.py +0 -0
  29. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/messages.py +0 -0
  30. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/perf.py +0 -0
  31. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/sessions.py +0 -0
  32. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_application.py +0 -0
  45. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_builder.py +0 -0
  46. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_parser.py +0 -0
  47. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_spec_parser.py +0 -0
  48. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_tags.py +0 -0
  49. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_time.py +0 -0
  50. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/housekeeping.py +0 -0
  51. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/inbound.py +0 -0
  52. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/json_store.py +0 -0
  53. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/latency_csv_writer.py +0 -0
  54. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_charts.py +0 -0
  58. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_engine.py +0 -0
  59. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_models.py +0 -0
  60. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_payload.py +0 -0
  61. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_stats.py +0 -0
  62. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_store.py +0 -0
  63. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_writer.py +0 -0
  64. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/session.py +0 -0
  65. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/session_manager.py +0 -0
  66. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/spec_overlay_store.py +0 -0
  67. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/template_store.py +0 -0
  68. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/user_store.py +0 -0
  69. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/venue_responses.py +0 -0
  70. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/fix_specs/FIX42.xml +0 -0
  71. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/fix_specs/FIX44.xml +0 -0
  72. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/server.py +0 -0
  73. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  74. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  75. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/setup.cfg +0 -0
  83. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_health.py +0 -0
  90. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_api.py +0 -0
  94. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_engine.py +0 -0
  95. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_models.py +0 -0
  96. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_payload.py +0 -0
  97. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_rehydrate.py +0 -0
  98. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_auto_response.py +0 -0
  99. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_lifecycle.py +0 -0
  100. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_manager_concurrency.py +0 -0
  101. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_sessions.py +0 -0
  102. {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_templates.py +0 -0
  103. {fixtureqa-0.4.33 → fixtureqa-0.5.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.5.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)
@@ -281,6 +281,22 @@ class ScenarioResponse(BaseModel):
281
281
  steps: list[ScenarioStepDef]
282
282
 
283
283
 
284
+ class ScenarioGroupRequest(BaseModel):
285
+ name: str
286
+ scenario_ids: list[str] = []
287
+
288
+
289
+ class ScenarioGroupResponse(BaseModel):
290
+ id: str
291
+ name: str
292
+ scenario_ids: list[str] = []
293
+
294
+
295
+ class GroupRunRequest(BaseModel):
296
+ """Ad-hoc launch of several scenarios as one coordinated group."""
297
+ scenario_ids: list[str]
298
+
299
+
284
300
  class TemplateRequest(BaseModel):
285
301
  name: str
286
302
  msg_type: str
@@ -144,6 +144,14 @@ class ScenarioRunner:
144
144
  self._ts = template_store
145
145
  self._broadcast = broadcast_fn
146
146
  self._abort_flags: dict[str, tuple[str, threading.Event]] = {} # run_id -> (owner_uid, flag)
147
+ # group_run_id -> {uid, run_ids, pending, statuses, started_ms}. A group
148
+ # is a set of single-session scenarios launched together; they coordinate
149
+ # purely through the FIX traffic flowing between their sessions (a venue
150
+ # scenario's `expect` matches the order a client scenario `send`s). The
151
+ # only thing the group adds over N independent runs is a *ready barrier*:
152
+ # every member's inbox is subscribed before any member thread can send,
153
+ # so the first message can never slip through before its waiter listens.
154
+ self._groups: dict[str, dict] = {}
147
155
  self._lock = threading.Lock()
148
156
  # The FastAPI/uvicorn event loop. Scenario steps run in worker threads
149
157
  # and must submit send coroutines back to this loop via
@@ -161,14 +169,73 @@ class ScenarioRunner:
161
169
  abort_flag = threading.Event()
162
170
  with self._lock:
163
171
  self._abort_flags[run_id] = (uid, abort_flag)
172
+ # Subscribe the inbox before the thread starts so a reply landing before
173
+ # the first expect step is buffered (see _RunInbox). The thread owns
174
+ # teardown (unsubscribe in _execute's finally).
175
+ inbox = _RunInbox(scenario.get("session_id", ""))
176
+ self._sm.subscribe(inbox.handler)
164
177
  t = threading.Thread(
165
178
  target=self._execute,
166
- args=(scenario, uid, run_id, abort_flag),
179
+ args=(scenario, uid, run_id, abort_flag, inbox),
167
180
  daemon=True,
168
181
  )
169
182
  t.start()
170
183
  return run_id
171
184
 
185
+ def run_group(self, scenarios: list[dict], uid: str) -> tuple[str, list[str]]:
186
+ """Launch several scenarios concurrently as one coordinated group.
187
+
188
+ Returns (group_run_id, run_ids). Every member's inbox is subscribed here,
189
+ synchronously, *before* any member thread starts — that is the ready
190
+ barrier: a venue scenario waiting on 35=D is guaranteed to be listening
191
+ before a client scenario's thread can send it (emergent coordination via
192
+ the real FIX message flow, no explicit signalling). Ownership of each
193
+ scenario's session is enforced by the caller (route)."""
194
+ group_run_id = str(uuid.uuid4())
195
+ members: list[tuple[dict, str, threading.Event, _RunInbox]] = []
196
+ for scenario in scenarios:
197
+ run_id = str(uuid.uuid4())
198
+ abort_flag = threading.Event()
199
+ inbox = _RunInbox(scenario.get("session_id", ""))
200
+ with self._lock:
201
+ self._abort_flags[run_id] = (uid, abort_flag)
202
+ self._sm.subscribe(inbox.handler) # barrier: listen before anyone sends
203
+ members.append((scenario, run_id, abort_flag, inbox))
204
+
205
+ run_ids = [m[1] for m in members]
206
+ with self._lock:
207
+ self._groups[group_run_id] = {
208
+ "uid": uid,
209
+ "run_ids": run_ids,
210
+ "pending": len(members),
211
+ "statuses": [],
212
+ "started_ms": _now_ms(),
213
+ }
214
+
215
+ self._broadcast(uid, {
216
+ "type": "scenario_group_start",
217
+ "group_run_id": group_run_id,
218
+ "runs": [
219
+ {
220
+ "run_id": run_id,
221
+ "scenario_id": scenario.get("id", ""),
222
+ "scenario_name": scenario.get("name", ""),
223
+ "session_id": scenario.get("session_id", ""),
224
+ "step_count": len(scenario.get("steps", [])),
225
+ }
226
+ for scenario, run_id, _flag, _inbox in members
227
+ ],
228
+ })
229
+
230
+ for scenario, run_id, abort_flag, inbox in members:
231
+ t = threading.Thread(
232
+ target=self._execute,
233
+ args=(scenario, uid, run_id, abort_flag, inbox, group_run_id),
234
+ daemon=True,
235
+ )
236
+ t.start()
237
+ return group_run_id, run_ids
238
+
172
239
  def abort(self, run_id: str, uid: str = "", is_admin: bool = False) -> None:
173
240
  """Signal abort for a running scenario. Only the run's owner (or an
174
241
  admin) may abort it; raises PermissionError otherwise. Unknown run_ids
@@ -182,7 +249,18 @@ class ScenarioRunner:
182
249
  raise PermissionError(f"run {run_id!r} is not owned by this user")
183
250
  flag.set()
184
251
 
185
- def _execute(self, scenario: dict, uid: str, run_id: str, abort_flag: threading.Event) -> None:
252
+ def abort_group(self, group_run_id: str, uid: str = "", is_admin: bool = False) -> None:
253
+ """Abort every member of a group run. Ownership is checked per member
254
+ (all members share the group's owner). Unknown group ids are a no-op."""
255
+ with self._lock:
256
+ group = self._groups.get(group_run_id)
257
+ run_ids = list(group["run_ids"]) if group else []
258
+ for run_id in run_ids:
259
+ self.abort(run_id, uid=uid, is_admin=is_admin)
260
+
261
+ def _execute(self, scenario: dict, uid: str, run_id: str,
262
+ abort_flag: threading.Event, inbox: _RunInbox,
263
+ group_run_id: Optional[str] = None) -> None:
186
264
  scenario_id = scenario.get("id", "")
187
265
  scenario_name = scenario.get("name", "")
188
266
  steps = scenario.get("steps", [])
@@ -193,20 +271,20 @@ class ScenarioRunner:
193
271
  "run_id": run_id,
194
272
  "scenario_id": scenario_id,
195
273
  "scenario_name": scenario_name,
274
+ "session_id": scenario.get("session_id", ""),
275
+ "group_run_id": group_run_id,
196
276
  "step_count": step_count,
197
277
  })
198
278
 
199
279
  run_start = _now_ms()
200
280
  overall_status = "pass"
201
281
 
202
- # One subscription for the whole run, registered before any step
203
- # executes, so replies arriving between steps are buffered for the
204
- # next expect instead of being missed.
205
- inbox = _RunInbox(scenario.get("session_id", ""))
206
- self._sm.subscribe(inbox.handler)
282
+ # The inbox is already subscribed (by run/run_group, before this thread
283
+ # started) so replies arriving before/between steps are buffered for the
284
+ # next expect instead of being missed. This thread owns teardown.
207
285
  try:
208
286
  overall_status = self._run_steps(scenario, uid, run_id, abort_flag,
209
- steps, inbox, overall_status)
287
+ steps, inbox, overall_status, group_run_id)
210
288
  finally:
211
289
  self._sm.unsubscribe(inbox.handler)
212
290
 
@@ -218,6 +296,7 @@ class ScenarioRunner:
218
296
  self._broadcast(uid, {
219
297
  "type": "scenario_complete",
220
298
  "run_id": run_id,
299
+ "group_run_id": group_run_id,
221
300
  "status": overall_status,
222
301
  "duration_ms": total_ms,
223
302
  })
@@ -225,9 +304,41 @@ class ScenarioRunner:
225
304
  with self._lock:
226
305
  self._abort_flags.pop(run_id, None)
227
306
 
307
+ if group_run_id is not None:
308
+ self._on_child_complete(group_run_id, uid, overall_status)
309
+
310
+ def _on_child_complete(self, group_run_id: str, uid: str, status: str) -> None:
311
+ """Record a member's terminal status; when the last member finishes,
312
+ broadcast the aggregate group result (fail > aborted > pass)."""
313
+ with self._lock:
314
+ group = self._groups.get(group_run_id)
315
+ if group is None:
316
+ return
317
+ group["statuses"].append(status)
318
+ group["pending"] -= 1
319
+ if group["pending"] > 0:
320
+ return
321
+ statuses = group["statuses"]
322
+ started_ms = group["started_ms"]
323
+ del self._groups[group_run_id]
324
+
325
+ if "fail" in statuses:
326
+ agg = "fail"
327
+ elif "aborted" in statuses:
328
+ agg = "aborted"
329
+ else:
330
+ agg = "pass"
331
+
332
+ self._broadcast(uid, {
333
+ "type": "scenario_group_complete",
334
+ "group_run_id": group_run_id,
335
+ "status": agg,
336
+ "duration_ms": int(_now_ms() - started_ms),
337
+ })
338
+
228
339
  def _run_steps(self, scenario: dict, uid: str, run_id: str,
229
340
  abort_flag: threading.Event, steps: list, inbox: _RunInbox,
230
- overall_status: str) -> str:
341
+ overall_status: str, group_run_id: Optional[str] = None) -> str:
231
342
  for idx, step in enumerate(steps):
232
343
  if abort_flag.is_set():
233
344
  overall_status = "aborted"
@@ -239,6 +350,7 @@ class ScenarioRunner:
239
350
  self._broadcast(uid, {
240
351
  "type": "scenario_step",
241
352
  "run_id": run_id,
353
+ "group_run_id": group_run_id,
242
354
  "step_index": idx,
243
355
  "label": label,
244
356
  "status": "running",
@@ -272,6 +384,7 @@ class ScenarioRunner:
272
384
  self._broadcast(uid, {
273
385
  "type": "scenario_step",
274
386
  "run_id": run_id,
387
+ "group_run_id": group_run_id,
275
388
  "step_index": idx,
276
389
  "label": label,
277
390
  "status": status,
@@ -34,3 +34,30 @@ class ScenarioStore(JsonListStore):
34
34
 
35
35
  def delete_scenario(self, uid: str, scenario_id: str) -> bool:
36
36
  return self.delete_item(uid, scenario_id)
37
+
38
+
39
+ class ScenarioGroupStore(JsonListStore):
40
+ """A suite = a named, saved grouping of scenario ids launched together as
41
+ one coordinated run (data/<uid>/scenario_groups.json)."""
42
+
43
+ def __init__(self, data_dir: str):
44
+ super().__init__()
45
+ self._data_dir = os.path.abspath(data_dir)
46
+
47
+ def _file(self, uid: str) -> str:
48
+ return os.path.join(self._data_dir, uid, "scenario_groups.json")
49
+
50
+ def list_groups(self, uid: str) -> list[dict]:
51
+ return self.list_items(uid)
52
+
53
+ def get_group(self, uid: str, group_id: str) -> Optional[dict]:
54
+ return self.get_item(uid, group_id)
55
+
56
+ def create_group(self, uid: str, data: dict) -> dict:
57
+ return self.create_item(uid, data)
58
+
59
+ def update_group(self, uid: str, group_id: str, data: dict) -> Optional[dict]:
60
+ return self.update_item(uid, group_id, data)
61
+
62
+ def delete_group(self, uid: str, group_id: str) -> bool:
63
+ return self.delete_item(uid, group_id)