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.
- {fixtureqa-0.4.33/fixtureqa.egg-info → fixtureqa-0.6.0}/PKG-INFO +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/app.py +2 -1
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/deps.py +5 -1
- fixtureqa-0.6.0/fixture/api/routers/scenarios.py +165 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/schemas.py +19 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_payload.py +10 -41
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/scenario_runner.py +219 -64
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/scenario_store.py +27 -0
- fixtureqa-0.6.0/fixture/core/value_gen.py +100 -0
- fixtureqa-0.6.0/fixture/static/assets/index-C46Em7NG.js +102 -0
- fixtureqa-0.4.33/fixture/static/assets/index-Ycej0J6b.css → fixtureqa-0.6.0/fixture/static/assets/index-DKAYJxvL.css +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.4.33 → fixtureqa-0.6.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/pyproject.toml +1 -1
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_scenarios.py +155 -0
- fixtureqa-0.6.0/tests/test_value_gen.py +45 -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.6.0}/LICENSE +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/README.md +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/server.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/setup.cfg +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_health.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.33 → fixtureqa-0.6.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)
|
|
@@ -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
|
|
99
|
+
# -- value generators -------------------------------------------------------
|
|
100
|
+
# The generator set (uuid()/seq()/random_*()/timestamp()/date()) lives in the
|
|
101
|
+
# shared core.value_gen module so perf and the scenario runner evaluate field
|
|
102
|
+
# values identically. Aliased here to keep the call sites below unchanged.
|
|
100
103
|
|
|
101
|
-
_GEN_RE =
|
|
102
|
-
|
|
103
|
-
_GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
104
|
+
_GEN_RE = value_gen.GEN_RE
|
|
105
|
+
_GENERATOR_NAMES = value_gen.GENERATOR_NAMES
|
|
104
106
|
_EXPIRY_UNIT_S = {"minutes": 60, "hours": 3600, "days": 86400}
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
def _gen_call(name: str, raw_args: str, tag: int, counters: dict[int, int]) -> Optional[str]:
|
|
108
|
-
|
|
109
|
-
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
110
|
-
if name == "uuid":
|
|
111
|
-
return _uuid()
|
|
112
|
-
if name == "random_str":
|
|
113
|
-
n = _to_int(args[0], 8) if args else 8
|
|
114
|
-
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
115
|
-
if name == "random_int":
|
|
116
|
-
try:
|
|
117
|
-
a, b = int(float(args[0])), int(float(args[1]))
|
|
118
|
-
except (IndexError, TypeError, ValueError):
|
|
119
|
-
return None
|
|
120
|
-
return str(random.randint(min(a, b), max(a, b)))
|
|
121
|
-
if name == "seq":
|
|
122
|
-
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
123
|
-
# zero-padded to width; one counter per tag per run.
|
|
124
|
-
width = _to_int(args[0], 0) if args else 0
|
|
125
|
-
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
126
|
-
n = counters.get(tag, start)
|
|
127
|
-
counters[tag] = n + 1
|
|
128
|
-
return str(n).zfill(width) if width > 0 else str(n)
|
|
129
|
-
if name == "timestamp":
|
|
130
|
-
return utc_timestamp()
|
|
131
|
-
if name == "date":
|
|
132
|
-
return utc_timestamp()[:8]
|
|
133
|
-
return None
|
|
110
|
+
return value_gen.gen_call(name, raw_args, tag, counters)
|
|
134
111
|
|
|
135
112
|
|
|
136
113
|
def _eval_generator(value: str, tag: int, counters: dict[int, int]) -> str:
|
|
137
|
-
|
|
138
|
-
m = _GEN_RE.match(value.strip())
|
|
139
|
-
if not m:
|
|
140
|
-
return value
|
|
141
|
-
out = _gen_call(m.group(1), m.group(2), tag, counters)
|
|
142
|
-
return value if out is None else out
|
|
114
|
+
return value_gen.eval_generator(value, tag, counters)
|
|
143
115
|
|
|
144
116
|
|
|
145
117
|
def _is_dynamic(value: str) -> bool:
|
|
146
|
-
|
|
147
|
-
(contains a {token} or is a whole-value generator call)."""
|
|
148
|
-
return isinstance(value, str) and (
|
|
149
|
-
"{" in value or _GEN_RE.match(value.strip()) is not None)
|
|
118
|
+
return value_gen.is_dynamic(value)
|
|
150
119
|
|
|
151
120
|
|
|
152
121
|
class PayloadFactory:
|