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.
Files changed (54) hide show
  1. fixture/__init__.py +22 -0
  2. fixture/__main__.py +161 -0
  3. fixture/api/__init__.py +0 -0
  4. fixture/api/app.py +95 -0
  5. fixture/api/connection_manager.py +161 -0
  6. fixture/api/deps.py +73 -0
  7. fixture/api/routers/__init__.py +0 -0
  8. fixture/api/routers/admin.py +178 -0
  9. fixture/api/routers/auth.py +74 -0
  10. fixture/api/routers/branding.py +33 -0
  11. fixture/api/routers/fix_spec.py +41 -0
  12. fixture/api/routers/messages.py +137 -0
  13. fixture/api/routers/scenarios.py +65 -0
  14. fixture/api/routers/sessions.py +272 -0
  15. fixture/api/routers/setup.py +42 -0
  16. fixture/api/routers/templates.py +36 -0
  17. fixture/api/routers/ws.py +129 -0
  18. fixture/api/schemas.py +289 -0
  19. fixture/config/__init__.py +0 -0
  20. fixture/core/__init__.py +0 -0
  21. fixture/core/auth.py +68 -0
  22. fixture/core/config_store.py +85 -0
  23. fixture/core/events.py +22 -0
  24. fixture/core/fix_application.py +67 -0
  25. fixture/core/fix_parser.py +79 -0
  26. fixture/core/fix_spec_parser.py +172 -0
  27. fixture/core/fix_tags.py +297 -0
  28. fixture/core/housekeeping.py +107 -0
  29. fixture/core/message_log.py +115 -0
  30. fixture/core/message_store.py +246 -0
  31. fixture/core/models.py +87 -0
  32. fixture/core/scenario_runner.py +331 -0
  33. fixture/core/scenario_store.py +70 -0
  34. fixture/core/session.py +278 -0
  35. fixture/core/session_manager.py +173 -0
  36. fixture/core/template_store.py +70 -0
  37. fixture/core/user_store.py +186 -0
  38. fixture/core/venue_responses.py +94 -0
  39. fixture/fix_specs/FIX42.xml +2746 -0
  40. fixture/fix_specs/FIX44.xml +6593 -0
  41. fixture/server.py +37 -0
  42. fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  43. fixture/static/assets/index-B31-1dt-.css +1 -0
  44. fixture/static/assets/index-CTsKxGdI.js +87 -0
  45. fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  46. fixture/static/favicon.svg +12 -0
  47. fixture/static/index.html +15 -0
  48. fixture/ui/__init__.py +0 -0
  49. fixtureqa-0.1.0.dist-info/METADATA +16 -0
  50. fixtureqa-0.1.0.dist-info/RECORD +54 -0
  51. fixtureqa-0.1.0.dist-info/WHEEL +5 -0
  52. fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
  53. fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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