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.
- {fixtureqa-0.4.33/fixtureqa.egg-info → fixtureqa-0.5.0}/PKG-INFO +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/app.py +2 -1
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/deps.py +5 -1
- fixtureqa-0.5.0/fixture/api/routers/scenarios.py +165 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/schemas.py +16 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/scenario_runner.py +122 -9
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/scenario_store.py +27 -0
- fixtureqa-0.4.33/fixture/static/assets/index-Ycej0J6b.css → fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css +1 -1
- fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js +102 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.4.33 → fixtureqa-0.5.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/SOURCES.txt +2 -2
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/pyproject.toml +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_scenarios.py +78 -0
- fixtureqa-0.4.33/fixture/api/routers/scenarios.py +0 -79
- fixtureqa-0.4.33/fixture/static/assets/index-C7XDCotT.js +0 -102
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/LICENSE +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/README.md +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/server.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/setup.cfg +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_health.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.5.0}/tests/test_ws.py +0 -0
|
@@ -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
|
|
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
|
-
#
|
|
203
|
-
#
|
|
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)
|