fixtureqa 0.1.0__py3-none-any.whl
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.
- fixture/__init__.py +22 -0
- fixture/__main__.py +161 -0
- fixture/api/__init__.py +0 -0
- fixture/api/app.py +95 -0
- fixture/api/connection_manager.py +161 -0
- fixture/api/deps.py +73 -0
- fixture/api/routers/__init__.py +0 -0
- fixture/api/routers/admin.py +178 -0
- fixture/api/routers/auth.py +74 -0
- fixture/api/routers/branding.py +33 -0
- fixture/api/routers/fix_spec.py +41 -0
- fixture/api/routers/messages.py +137 -0
- fixture/api/routers/scenarios.py +65 -0
- fixture/api/routers/sessions.py +272 -0
- fixture/api/routers/setup.py +42 -0
- fixture/api/routers/templates.py +36 -0
- fixture/api/routers/ws.py +129 -0
- fixture/api/schemas.py +289 -0
- fixture/config/__init__.py +0 -0
- fixture/core/__init__.py +0 -0
- fixture/core/auth.py +68 -0
- fixture/core/config_store.py +85 -0
- fixture/core/events.py +22 -0
- fixture/core/fix_application.py +67 -0
- fixture/core/fix_parser.py +79 -0
- fixture/core/fix_spec_parser.py +172 -0
- fixture/core/fix_tags.py +297 -0
- fixture/core/housekeeping.py +107 -0
- fixture/core/message_log.py +115 -0
- fixture/core/message_store.py +246 -0
- fixture/core/models.py +87 -0
- fixture/core/scenario_runner.py +331 -0
- fixture/core/scenario_store.py +70 -0
- fixture/core/session.py +278 -0
- fixture/core/session_manager.py +173 -0
- fixture/core/template_store.py +70 -0
- fixture/core/user_store.py +186 -0
- fixture/core/venue_responses.py +94 -0
- fixture/fix_specs/FIX42.xml +2746 -0
- fixture/fix_specs/FIX44.xml +6593 -0
- fixture/server.py +37 -0
- fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
- fixture/static/assets/index-B31-1dt-.css +1 -0
- fixture/static/assets/index-CTsKxGdI.js +87 -0
- fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
- fixture/static/favicon.svg +12 -0
- fixture/static/index.html +15 -0
- fixture/ui/__init__.py +0 -0
- fixtureqa-0.1.0.dist-info/METADATA +16 -0
- fixtureqa-0.1.0.dist-info/RECORD +54 -0
- fixtureqa-0.1.0.dist-info/WHEEL +5 -0
- fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
- fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite-backed message persistence.
|
|
3
|
+
|
|
4
|
+
MessageWriter — one shared instance per app; owns the background writer thread.
|
|
5
|
+
MessageStore — per-session interface; same API as MessageLog (drop-in replacement).
|
|
6
|
+
|
|
7
|
+
The QuickFIX I/O thread calls MessageStore.add() which parses the message
|
|
8
|
+
synchronously and enqueues the DB write. The background thread batches
|
|
9
|
+
inserts so the I/O thread is never blocked by SQLite.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import queue
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .fix_parser import parse_raw, get_msg_type, get_seq_num
|
|
20
|
+
from .fix_tags import msg_type_name
|
|
21
|
+
from .message_log import LogEntry
|
|
22
|
+
|
|
23
|
+
_CREATE_TABLE = """
|
|
24
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
session_id TEXT NOT NULL,
|
|
27
|
+
ts TEXT NOT NULL,
|
|
28
|
+
direction TEXT NOT NULL,
|
|
29
|
+
admin INTEGER NOT NULL,
|
|
30
|
+
seq_num INTEGER NOT NULL,
|
|
31
|
+
msg_type TEXT NOT NULL,
|
|
32
|
+
msg_type_name TEXT NOT NULL,
|
|
33
|
+
raw TEXT NOT NULL,
|
|
34
|
+
fields_json TEXT NOT NULL
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
_CREATE_INDEX = """
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_msg_session ON messages(session_id, id DESC)
|
|
39
|
+
"""
|
|
40
|
+
_INSERT = """
|
|
41
|
+
INSERT INTO messages
|
|
42
|
+
(session_id, ts, direction, admin, seq_num, msg_type, msg_type_name, raw, fields_json)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
|
|
48
|
+
return (
|
|
49
|
+
session_id,
|
|
50
|
+
entry.timestamp.isoformat(),
|
|
51
|
+
entry.direction,
|
|
52
|
+
1 if entry.admin else 0,
|
|
53
|
+
entry.seq_num,
|
|
54
|
+
entry.msg_type,
|
|
55
|
+
entry.msg_type_name,
|
|
56
|
+
entry.raw,
|
|
57
|
+
json.dumps(entry.fields),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _row_to_entry(row) -> LogEntry:
|
|
62
|
+
row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json = row
|
|
63
|
+
return LogEntry(
|
|
64
|
+
direction=direction,
|
|
65
|
+
admin=bool(admin),
|
|
66
|
+
raw=raw,
|
|
67
|
+
timestamp=datetime.fromisoformat(ts),
|
|
68
|
+
seq_num=seq_num,
|
|
69
|
+
msg_type=mt,
|
|
70
|
+
msg_type_name=mt_name,
|
|
71
|
+
fields=json.loads(fields_json),
|
|
72
|
+
id=row_id,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MessageWriter:
|
|
77
|
+
"""
|
|
78
|
+
Shared background SQLite writer for all sessions.
|
|
79
|
+
|
|
80
|
+
One instance per application. All MessageStore instances for the same
|
|
81
|
+
app share this writer. The background thread batches inserts to keep
|
|
82
|
+
write throughput high without blocking the QuickFIX I/O thread.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, db_path: str):
|
|
86
|
+
self._db_path = db_path
|
|
87
|
+
self._queue: queue.Queue = queue.Queue()
|
|
88
|
+
self._init_db()
|
|
89
|
+
self._thread = threading.Thread(
|
|
90
|
+
target=self._run, name="msg-writer", daemon=True
|
|
91
|
+
)
|
|
92
|
+
self._thread.start()
|
|
93
|
+
|
|
94
|
+
def _init_db(self) -> None:
|
|
95
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
96
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
97
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
98
|
+
conn.execute(_CREATE_TABLE)
|
|
99
|
+
conn.execute(_CREATE_INDEX)
|
|
100
|
+
|
|
101
|
+
def enqueue(self, session_id: str, entry: LogEntry) -> None:
|
|
102
|
+
"""Called from any thread. Never blocks."""
|
|
103
|
+
self._queue.put((session_id, entry))
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Flush remaining writes and stop the background thread."""
|
|
107
|
+
self._queue.put(None) # sentinel
|
|
108
|
+
self._thread.join(timeout=10.0)
|
|
109
|
+
|
|
110
|
+
def _run(self) -> None:
|
|
111
|
+
conn = sqlite3.connect(self._db_path)
|
|
112
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
113
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
114
|
+
stop = False
|
|
115
|
+
try:
|
|
116
|
+
while not stop:
|
|
117
|
+
batch = []
|
|
118
|
+
# Block up to 100 ms waiting for the first item
|
|
119
|
+
try:
|
|
120
|
+
item = self._queue.get(timeout=0.1)
|
|
121
|
+
if item is None:
|
|
122
|
+
stop = True
|
|
123
|
+
else:
|
|
124
|
+
batch.append(item)
|
|
125
|
+
# Drain additional items without blocking
|
|
126
|
+
while len(batch) < 200:
|
|
127
|
+
try:
|
|
128
|
+
item = self._queue.get_nowait()
|
|
129
|
+
if item is None:
|
|
130
|
+
stop = True
|
|
131
|
+
break
|
|
132
|
+
batch.append(item)
|
|
133
|
+
except queue.Empty:
|
|
134
|
+
break
|
|
135
|
+
except queue.Empty:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
if batch:
|
|
139
|
+
conn.executemany(
|
|
140
|
+
_INSERT,
|
|
141
|
+
[_entry_to_row(sid, e) for sid, e in batch],
|
|
142
|
+
)
|
|
143
|
+
conn.commit()
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class MessageStore:
|
|
149
|
+
"""
|
|
150
|
+
Per-session message store backed by SQLite via a shared MessageWriter.
|
|
151
|
+
|
|
152
|
+
Drop-in replacement for MessageLog: same add/get_entries/clear/__len__ API.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, session_id: str, writer: MessageWriter):
|
|
156
|
+
self._session_id = session_id
|
|
157
|
+
self._writer = writer
|
|
158
|
+
self._db_path = writer._db_path
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Write
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def add(self, direction: str, admin: bool, raw: str) -> LogEntry:
|
|
165
|
+
"""Parse message, enqueue DB write, return LogEntry immediately."""
|
|
166
|
+
fields = parse_raw(raw)
|
|
167
|
+
mt = get_msg_type(fields)
|
|
168
|
+
entry = LogEntry(
|
|
169
|
+
direction=direction,
|
|
170
|
+
admin=admin,
|
|
171
|
+
raw=raw,
|
|
172
|
+
timestamp=datetime.now(tz=timezone.utc),
|
|
173
|
+
seq_num=get_seq_num(fields),
|
|
174
|
+
msg_type=mt,
|
|
175
|
+
msg_type_name=msg_type_name(mt) if mt else "Unknown",
|
|
176
|
+
fields=fields,
|
|
177
|
+
)
|
|
178
|
+
self._writer.enqueue(self._session_id, entry)
|
|
179
|
+
return entry
|
|
180
|
+
|
|
181
|
+
def clear(self) -> None:
|
|
182
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
183
|
+
conn.execute(
|
|
184
|
+
"DELETE FROM messages WHERE session_id = ?", (self._session_id,)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Read
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def get_entries(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
direction: Optional[str] = None,
|
|
195
|
+
admin: Optional[bool] = None,
|
|
196
|
+
msg_type: Optional[str] = None,
|
|
197
|
+
limit: Optional[int] = None,
|
|
198
|
+
before_id: Optional[int] = None, # cursor: return entries with id < before_id
|
|
199
|
+
) -> list[LogEntry]:
|
|
200
|
+
"""Return entries matching filters, ordered oldest-first.
|
|
201
|
+
|
|
202
|
+
When before_id is set, only rows with id < before_id are considered
|
|
203
|
+
(cursor-based pagination for "Load older" queries).
|
|
204
|
+
When limit is set, the last N qualifying rows are returned.
|
|
205
|
+
"""
|
|
206
|
+
where = ["session_id = ?"]
|
|
207
|
+
params: list = [self._session_id]
|
|
208
|
+
|
|
209
|
+
if direction is not None:
|
|
210
|
+
where.append("direction = ?")
|
|
211
|
+
params.append(direction)
|
|
212
|
+
if admin is not None:
|
|
213
|
+
where.append("admin = ?")
|
|
214
|
+
params.append(1 if admin else 0)
|
|
215
|
+
if msg_type is not None:
|
|
216
|
+
where.append("msg_type = ?")
|
|
217
|
+
params.append(msg_type)
|
|
218
|
+
if before_id is not None:
|
|
219
|
+
where.append("id < ?")
|
|
220
|
+
params.append(before_id)
|
|
221
|
+
|
|
222
|
+
where_sql = " AND ".join(where)
|
|
223
|
+
select = (
|
|
224
|
+
"SELECT id, session_id, ts, direction, admin, seq_num, "
|
|
225
|
+
f"msg_type, msg_type_name, raw, fields_json FROM messages WHERE {where_sql}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if limit is not None:
|
|
229
|
+
# Fetch last N qualifying rows in ascending order
|
|
230
|
+
sql = f"SELECT * FROM ({select} ORDER BY id DESC LIMIT ?) ORDER BY id ASC"
|
|
231
|
+
params.append(limit)
|
|
232
|
+
else:
|
|
233
|
+
sql = f"{select} ORDER BY id ASC"
|
|
234
|
+
|
|
235
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
236
|
+
rows = conn.execute(sql, params).fetchall()
|
|
237
|
+
|
|
238
|
+
return [_row_to_entry(r) for r in rows]
|
|
239
|
+
|
|
240
|
+
def __len__(self) -> int:
|
|
241
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
242
|
+
row = conn.execute(
|
|
243
|
+
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
|
244
|
+
(self._session_id,),
|
|
245
|
+
).fetchone()
|
|
246
|
+
return row[0] if row else 0
|
fixture/core/models.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from typing import Optional, TYPE_CHECKING
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .message_log import MessageLog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionType(Enum):
|
|
12
|
+
INITIATOR = "initiator"
|
|
13
|
+
ACCEPTOR = "acceptor"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionRole(Enum):
|
|
17
|
+
CLIENT = "client" # sends orders
|
|
18
|
+
VENUE = "venue" # receives orders, sends fills
|
|
19
|
+
GENERIC = "generic" # post-trade, IOIs, etc.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionStatus(Enum):
|
|
23
|
+
STOPPED = auto()
|
|
24
|
+
CONNECTING = auto() # initiator: between start() and logon
|
|
25
|
+
LISTENING = auto() # acceptor: socket bound, no client yet
|
|
26
|
+
LOGGED_ON = auto()
|
|
27
|
+
LOGGED_OUT = auto() # engine running but no active logon
|
|
28
|
+
ERROR = auto()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SessionConfig:
|
|
33
|
+
# Identity
|
|
34
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
35
|
+
display_name: str = ""
|
|
36
|
+
|
|
37
|
+
# FIX identity
|
|
38
|
+
begin_string: str = "FIX.4.2" # "FIX.4.2" or "FIX.4.4"
|
|
39
|
+
sender_comp_id: str = ""
|
|
40
|
+
target_comp_id: str = ""
|
|
41
|
+
|
|
42
|
+
# Connection
|
|
43
|
+
connection_type: ConnectionType = ConnectionType.INITIATOR
|
|
44
|
+
host: str = "localhost" # ignored for acceptor
|
|
45
|
+
port: int = 9876
|
|
46
|
+
heartbeat_interval: int = 30
|
|
47
|
+
reconnect_interval: int = 10 # initiator only
|
|
48
|
+
|
|
49
|
+
# Data dictionary (None = use bundled spec)
|
|
50
|
+
data_dictionary_path: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
# Store / log paths (use per-session subdirs to avoid collisions)
|
|
53
|
+
file_store_path: str = "./store"
|
|
54
|
+
file_log_path: str = "./log"
|
|
55
|
+
use_file_log: bool = True
|
|
56
|
+
|
|
57
|
+
# Session schedule ("00:00:00" for both = always active)
|
|
58
|
+
start_time: str = "00:00:00"
|
|
59
|
+
end_time: str = "00:00:00"
|
|
60
|
+
|
|
61
|
+
# Sequence number reset behaviour
|
|
62
|
+
reset_on_logon: bool = True
|
|
63
|
+
reset_on_logout: bool = False
|
|
64
|
+
|
|
65
|
+
# Role / intent
|
|
66
|
+
session_role: SessionRole = SessionRole.GENERIC
|
|
67
|
+
|
|
68
|
+
# Venue auto-responses (only active when session_role == VENUE)
|
|
69
|
+
auto_ack: bool = False
|
|
70
|
+
auto_ack_delay_ms: int = 100
|
|
71
|
+
auto_fill: bool = False
|
|
72
|
+
auto_fill_delay_ms: int = 500
|
|
73
|
+
|
|
74
|
+
# Ownership (empty string = legacy/unowned)
|
|
75
|
+
owner_uid: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class SessionState:
|
|
80
|
+
config: SessionConfig
|
|
81
|
+
status: SessionStatus = SessionStatus.STOPPED
|
|
82
|
+
error_message: str = ""
|
|
83
|
+
last_sent_seq_num: int = 0
|
|
84
|
+
last_recv_seq_num: int = 0
|
|
85
|
+
message_log: Optional[MessageLog] = field(default=None) # set by Session on init
|
|
86
|
+
status_changed_at: Optional[float] = None # time.time() of last status transition
|
|
87
|
+
last_heartbeat_at: Optional[float] = None # time.time() of last inbound Heartbeat
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executes scenarios in background threads and emits progress via a broadcast callback.
|
|
3
|
+
|
|
4
|
+
Each scenario run is a daemon thread that steps through send/expect/delay steps,
|
|
5
|
+
subscribing to SessionManager events for 'expect' steps and using threading.Event
|
|
6
|
+
for timeout and abort signalling.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from fixcore.message.message import Message
|
|
18
|
+
|
|
19
|
+
from .events import SessionEvent, EventType
|
|
20
|
+
from .fix_parser import parse_raw
|
|
21
|
+
from .session_manager import SessionManager
|
|
22
|
+
from .template_store import TemplateStore
|
|
23
|
+
|
|
24
|
+
SKIP_TAGS = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_ms() -> float:
|
|
28
|
+
return time.monotonic() * 1000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utc_ts() -> str:
|
|
32
|
+
return datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_raw_multivalue(raw: str) -> dict[str, list[str]]:
|
|
36
|
+
"""
|
|
37
|
+
Parse a raw FIX string preserving all occurrences of repeated tags.
|
|
38
|
+
Returns {tag_str: [val, val, ...]} so repeating group fields are not lost.
|
|
39
|
+
"""
|
|
40
|
+
result: dict[str, list[str]] = {}
|
|
41
|
+
normalised = raw.replace("|", "\x01") if "\x01" not in raw else raw
|
|
42
|
+
for pair in normalised.split("\x01"):
|
|
43
|
+
if "=" in pair:
|
|
44
|
+
tag, _, val = pair.partition("=")
|
|
45
|
+
tag = tag.strip()
|
|
46
|
+
if tag:
|
|
47
|
+
result.setdefault(tag, []).append(val)
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_assertion(fields: dict[str, list[str]], assertion: dict) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Return True if the message fields satisfy the assertion.
|
|
54
|
+
fields is a multi-value dict: {tag_str: [val, val, ...]}.
|
|
55
|
+
For eq/ne, the assertion passes if ANY occurrence satisfies it.
|
|
56
|
+
"""
|
|
57
|
+
tag = str(assertion.get("tag", ""))
|
|
58
|
+
op = assertion.get("op", "eq")
|
|
59
|
+
expected = str(assertion.get("value", ""))
|
|
60
|
+
|
|
61
|
+
values = fields.get(tag) # list[str] or None
|
|
62
|
+
|
|
63
|
+
if op == "exists":
|
|
64
|
+
return values is not None
|
|
65
|
+
if op == "absent":
|
|
66
|
+
return values is None
|
|
67
|
+
if values is None:
|
|
68
|
+
return False
|
|
69
|
+
if op == "eq":
|
|
70
|
+
return expected in values
|
|
71
|
+
if op == "ne":
|
|
72
|
+
return all(v != expected for v in values)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScenarioRunner:
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
session_manager: SessionManager,
|
|
80
|
+
template_store: TemplateStore,
|
|
81
|
+
broadcast_fn: Callable[[str, dict], None],
|
|
82
|
+
):
|
|
83
|
+
self._sm = session_manager
|
|
84
|
+
self._ts = template_store
|
|
85
|
+
self._broadcast = broadcast_fn
|
|
86
|
+
self._abort_flags: dict[str, threading.Event] = {}
|
|
87
|
+
self._lock = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def run(self, scenario: dict, uid: str) -> str:
|
|
90
|
+
"""Start scenario execution in a daemon thread. Returns run_id."""
|
|
91
|
+
run_id = str(uuid.uuid4())
|
|
92
|
+
abort_flag = threading.Event()
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._abort_flags[run_id] = abort_flag
|
|
95
|
+
t = threading.Thread(
|
|
96
|
+
target=self._execute,
|
|
97
|
+
args=(scenario, uid, run_id, abort_flag),
|
|
98
|
+
daemon=True,
|
|
99
|
+
)
|
|
100
|
+
t.start()
|
|
101
|
+
return run_id
|
|
102
|
+
|
|
103
|
+
def abort(self, run_id: str) -> None:
|
|
104
|
+
"""Signal abort for a running scenario."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
flag = self._abort_flags.get(run_id)
|
|
107
|
+
if flag:
|
|
108
|
+
flag.set()
|
|
109
|
+
|
|
110
|
+
def _execute(self, scenario: dict, uid: str, run_id: str, abort_flag: threading.Event) -> None:
|
|
111
|
+
scenario_id = scenario.get("id", "")
|
|
112
|
+
scenario_name = scenario.get("name", "")
|
|
113
|
+
steps = scenario.get("steps", [])
|
|
114
|
+
step_count = len(steps)
|
|
115
|
+
|
|
116
|
+
self._broadcast(uid, {
|
|
117
|
+
"type": "scenario_start",
|
|
118
|
+
"run_id": run_id,
|
|
119
|
+
"scenario_id": scenario_id,
|
|
120
|
+
"scenario_name": scenario_name,
|
|
121
|
+
"step_count": step_count,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
run_start = _now_ms()
|
|
125
|
+
overall_status = "pass"
|
|
126
|
+
|
|
127
|
+
for idx, step in enumerate(steps):
|
|
128
|
+
if abort_flag.is_set():
|
|
129
|
+
overall_status = "aborted"
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
label = step.get("label", f"Step {idx + 1}")
|
|
133
|
+
step_type = step.get("type", "")
|
|
134
|
+
|
|
135
|
+
self._broadcast(uid, {
|
|
136
|
+
"type": "scenario_step",
|
|
137
|
+
"run_id": run_id,
|
|
138
|
+
"step_index": idx,
|
|
139
|
+
"label": label,
|
|
140
|
+
"status": "running",
|
|
141
|
+
"error": None,
|
|
142
|
+
"duration_ms": 0,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
step_start = _now_ms()
|
|
146
|
+
error: Optional[str] = None
|
|
147
|
+
status = "pass"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
if step_type == "send":
|
|
151
|
+
error = self._run_send(step, scenario.get("session_id", ""), uid)
|
|
152
|
+
elif step_type == "expect":
|
|
153
|
+
error = self._run_expect(step, scenario.get("session_id", ""), abort_flag)
|
|
154
|
+
elif step_type == "delay":
|
|
155
|
+
self._run_delay(step, abort_flag)
|
|
156
|
+
if abort_flag.is_set():
|
|
157
|
+
overall_status = "aborted"
|
|
158
|
+
status = "fail"
|
|
159
|
+
error = "Aborted"
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
error = str(exc)
|
|
162
|
+
|
|
163
|
+
if error is not None:
|
|
164
|
+
status = "fail"
|
|
165
|
+
|
|
166
|
+
duration_ms = int(_now_ms() - step_start)
|
|
167
|
+
|
|
168
|
+
self._broadcast(uid, {
|
|
169
|
+
"type": "scenario_step",
|
|
170
|
+
"run_id": run_id,
|
|
171
|
+
"step_index": idx,
|
|
172
|
+
"label": label,
|
|
173
|
+
"status": status,
|
|
174
|
+
"error": error,
|
|
175
|
+
"duration_ms": duration_ms,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
if status == "fail":
|
|
179
|
+
if overall_status != "aborted":
|
|
180
|
+
overall_status = "fail"
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
if abort_flag.is_set() and overall_status == "pass":
|
|
184
|
+
overall_status = "aborted"
|
|
185
|
+
|
|
186
|
+
total_ms = int(_now_ms() - run_start)
|
|
187
|
+
|
|
188
|
+
self._broadcast(uid, {
|
|
189
|
+
"type": "scenario_complete",
|
|
190
|
+
"run_id": run_id,
|
|
191
|
+
"status": overall_status,
|
|
192
|
+
"duration_ms": total_ms,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
with self._lock:
|
|
196
|
+
self._abort_flags.pop(run_id, None)
|
|
197
|
+
|
|
198
|
+
def _run_send(self, step: dict, session_id: str, uid: str) -> Optional[str]:
|
|
199
|
+
"""Build and send a FIX message. Returns error string or None.
|
|
200
|
+
|
|
201
|
+
If step contains a 'raw' field it is used directly, which preserves
|
|
202
|
+
repeating group structure (the raw string is sent via parse_raw +
|
|
203
|
+
setField, same code path as the REST send endpoint).
|
|
204
|
+
Otherwise, template + step fields are merged into a flat dict.
|
|
205
|
+
"""
|
|
206
|
+
fields: dict[int, str] = {}
|
|
207
|
+
|
|
208
|
+
raw_field = step.get("raw", "").strip()
|
|
209
|
+
if raw_field:
|
|
210
|
+
# Use raw FIX string — this preserves repeated tags better than
|
|
211
|
+
# the flat fields dict. parse_raw still flattens (last-value wins)
|
|
212
|
+
# for each tag, so groups with truly identical tag numbers across
|
|
213
|
+
# instances will lose earlier values, but the raw string is sent
|
|
214
|
+
# exactly as provided to QuickFIX which handles the wire format.
|
|
215
|
+
normalised = raw_field.replace("|", "\x01") if "\x01" not in raw_field else raw_field
|
|
216
|
+
fields = parse_raw(normalised)
|
|
217
|
+
else:
|
|
218
|
+
# Load template fields first
|
|
219
|
+
template_id = step.get("template_id")
|
|
220
|
+
if template_id:
|
|
221
|
+
tpl = self._ts.get_template(uid, template_id)
|
|
222
|
+
if tpl is None:
|
|
223
|
+
return f"Template {template_id!r} not found"
|
|
224
|
+
for tag_str, value in tpl.get("fields", {}).items():
|
|
225
|
+
try:
|
|
226
|
+
fields[int(tag_str)] = value
|
|
227
|
+
except ValueError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Step-level field overrides
|
|
231
|
+
for tag_str, value in step.get("fields", {}).items():
|
|
232
|
+
try:
|
|
233
|
+
fields[int(tag_str)] = value
|
|
234
|
+
except ValueError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
msg_type = fields.get(35)
|
|
238
|
+
if not msg_type:
|
|
239
|
+
return "Missing MsgType (tag 35) in send step"
|
|
240
|
+
|
|
241
|
+
# Inject TransactTime if not present
|
|
242
|
+
if 60 not in fields:
|
|
243
|
+
fields[60] = _utc_ts()
|
|
244
|
+
|
|
245
|
+
msg = Message()
|
|
246
|
+
msg.header.set(35, msg_type)
|
|
247
|
+
for tag, value in fields.items():
|
|
248
|
+
if tag not in SKIP_TAGS:
|
|
249
|
+
try:
|
|
250
|
+
msg.set_field(int(tag), str(value))
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
import asyncio
|
|
255
|
+
loop = asyncio.get_event_loop()
|
|
256
|
+
ok = asyncio.run_coroutine_threadsafe(
|
|
257
|
+
self._sm.send_message(session_id, msg), loop
|
|
258
|
+
).result(timeout=5)
|
|
259
|
+
if not ok:
|
|
260
|
+
return "Session not logged on"
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def _run_expect(
|
|
264
|
+
self, step: dict, session_id: str, abort_flag: threading.Event
|
|
265
|
+
) -> Optional[str]:
|
|
266
|
+
"""Wait for an inbound message matching msg_type and assertions. Returns error or None."""
|
|
267
|
+
expected_msg_type = step.get("msg_type", "")
|
|
268
|
+
timeout_ms = step.get("timeout_ms", 5000)
|
|
269
|
+
assertions: list[dict] = step.get("assertions", [])
|
|
270
|
+
|
|
271
|
+
matched = threading.Event()
|
|
272
|
+
match_error: list[Optional[str]] = [None] # mutable container
|
|
273
|
+
|
|
274
|
+
def handler(event: SessionEvent) -> None:
|
|
275
|
+
if matched.is_set() or abort_flag.is_set():
|
|
276
|
+
return
|
|
277
|
+
if event.event_type != EventType.MESSAGE_RECEIVED:
|
|
278
|
+
return
|
|
279
|
+
if event.session_id != session_id:
|
|
280
|
+
return
|
|
281
|
+
payload = event.payload
|
|
282
|
+
if expected_msg_type and payload.get("msg_type", "") != expected_msg_type:
|
|
283
|
+
return
|
|
284
|
+
# Use multi-value parser on the raw string so repeating group fields
|
|
285
|
+
# are not lost. Falls back to the flat fields dict if raw is absent.
|
|
286
|
+
raw = payload.get("raw", "")
|
|
287
|
+
if raw:
|
|
288
|
+
fields = _parse_raw_multivalue(raw)
|
|
289
|
+
else:
|
|
290
|
+
fields = {str(k): [v] for k, v in payload.get("fields", {}).items()}
|
|
291
|
+
for assertion in assertions:
|
|
292
|
+
if not _check_assertion(fields, assertion):
|
|
293
|
+
tag = assertion.get("tag", "?")
|
|
294
|
+
op = assertion.get("op", "?")
|
|
295
|
+
value = assertion.get("value", "")
|
|
296
|
+
actual_list = fields.get(str(tag))
|
|
297
|
+
actual_repr = repr(actual_list[0]) if actual_list and len(actual_list) == 1 else repr(actual_list) if actual_list else "<absent>"
|
|
298
|
+
match_error[0] = (
|
|
299
|
+
f"Assertion failed: tag {tag} {op} {value!r} (actual: {actual_repr})"
|
|
300
|
+
)
|
|
301
|
+
matched.set()
|
|
302
|
+
return
|
|
303
|
+
matched.set()
|
|
304
|
+
|
|
305
|
+
self._sm.subscribe(handler)
|
|
306
|
+
try:
|
|
307
|
+
deadline = timeout_ms / 1000.0
|
|
308
|
+
poll_interval = 0.05
|
|
309
|
+
elapsed = 0.0
|
|
310
|
+
while elapsed < deadline:
|
|
311
|
+
if matched.is_set() or abort_flag.is_set():
|
|
312
|
+
break
|
|
313
|
+
time.sleep(poll_interval)
|
|
314
|
+
elapsed += poll_interval
|
|
315
|
+
finally:
|
|
316
|
+
self._sm.unsubscribe(handler)
|
|
317
|
+
|
|
318
|
+
if abort_flag.is_set():
|
|
319
|
+
return "Aborted"
|
|
320
|
+
if not matched.is_set():
|
|
321
|
+
desc = expected_msg_type or "any"
|
|
322
|
+
return f"Timeout waiting for message type {desc!r} after {timeout_ms}ms"
|
|
323
|
+
return match_error[0] # None = pass, non-None = assertion failure message
|
|
324
|
+
|
|
325
|
+
def _run_delay(self, step: dict, abort_flag: threading.Event) -> None:
|
|
326
|
+
delay_ms = step.get("delay_ms", 0)
|
|
327
|
+
remaining = delay_ms / 1000.0
|
|
328
|
+
interval = 0.05
|
|
329
|
+
while remaining > 0 and not abort_flag.is_set():
|
|
330
|
+
time.sleep(min(interval, remaining))
|
|
331
|
+
remaining -= interval
|