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,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-user scenario storage.
|
|
3
|
+
Scenarios are persisted as data/<uid>/scenarios.json — same directory structure
|
|
4
|
+
as template_store.py / config_store.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScenarioStore:
|
|
16
|
+
def __init__(self, data_dir: str):
|
|
17
|
+
self._data_dir = os.path.abspath(data_dir)
|
|
18
|
+
|
|
19
|
+
def _path(self, uid: str) -> str:
|
|
20
|
+
return os.path.join(self._data_dir, uid, "scenarios.json")
|
|
21
|
+
|
|
22
|
+
def _load(self, uid: str) -> list[dict]:
|
|
23
|
+
path = self._path(uid)
|
|
24
|
+
if not os.path.isfile(path):
|
|
25
|
+
return []
|
|
26
|
+
try:
|
|
27
|
+
with open(path) as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
def _save(self, uid: str, scenarios: list[dict]) -> None:
|
|
33
|
+
user_dir = os.path.join(self._data_dir, uid)
|
|
34
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
35
|
+
path = self._path(uid)
|
|
36
|
+
tmp = path + ".tmp"
|
|
37
|
+
with open(tmp, "w") as f:
|
|
38
|
+
json.dump(scenarios, f, indent=2)
|
|
39
|
+
os.replace(tmp, path)
|
|
40
|
+
|
|
41
|
+
def list_scenarios(self, uid: str) -> list[dict]:
|
|
42
|
+
return self._load(uid)
|
|
43
|
+
|
|
44
|
+
def get_scenario(self, uid: str, scenario_id: str) -> Optional[dict]:
|
|
45
|
+
return next((s for s in self._load(uid) if s.get("id") == scenario_id), None)
|
|
46
|
+
|
|
47
|
+
def create_scenario(self, uid: str, data: dict) -> dict:
|
|
48
|
+
scenarios = self._load(uid)
|
|
49
|
+
scenario = {**data, "id": str(uuid.uuid4())}
|
|
50
|
+
scenarios.append(scenario)
|
|
51
|
+
self._save(uid, scenarios)
|
|
52
|
+
return scenario
|
|
53
|
+
|
|
54
|
+
def update_scenario(self, uid: str, scenario_id: str, data: dict) -> Optional[dict]:
|
|
55
|
+
scenarios = self._load(uid)
|
|
56
|
+
for i, s in enumerate(scenarios):
|
|
57
|
+
if s.get("id") == scenario_id:
|
|
58
|
+
updated = {**data, "id": scenario_id}
|
|
59
|
+
scenarios[i] = updated
|
|
60
|
+
self._save(uid, scenarios)
|
|
61
|
+
return updated
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def delete_scenario(self, uid: str, scenario_id: str) -> bool:
|
|
65
|
+
scenarios = self._load(uid)
|
|
66
|
+
new = [s for s in scenarios if s.get("id") != scenario_id]
|
|
67
|
+
if len(new) == len(scenarios):
|
|
68
|
+
return False
|
|
69
|
+
self._save(uid, new)
|
|
70
|
+
return True
|
fixture/core/session.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, Optional
|
|
5
|
+
|
|
6
|
+
from fixcore.message.message import Message
|
|
7
|
+
from fixcore.session.session_id import SessionID
|
|
8
|
+
from fixcore.session.session_settings import SessionSettings
|
|
9
|
+
from fixcore.store import FileStoreFactory, MemoryStoreFactory
|
|
10
|
+
from fixcore.log import FileLogFactory, ScreenLogFactory
|
|
11
|
+
from fixcore.transport import SocketInitiator, SocketAcceptor
|
|
12
|
+
|
|
13
|
+
from .models import SessionConfig, SessionState, SessionStatus, ConnectionType
|
|
14
|
+
from .events import EventHandler, EventType, SessionEvent
|
|
15
|
+
from .fix_application import FixApplication
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .message_store import MessageWriter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Session:
|
|
22
|
+
"""
|
|
23
|
+
Owns exactly one fixcore transport (SocketInitiator or SocketAcceptor).
|
|
24
|
+
The transport runs as asyncio tasks on the same event loop as FastAPI —
|
|
25
|
+
no daemon threads or thread-crossing required.
|
|
26
|
+
|
|
27
|
+
Lifecycle: STOPPED -> await start() -> CONNECTING/LISTENING -> await stop() -> STOPPED
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: SessionConfig, event_handler: EventHandler,
|
|
31
|
+
msg_writer: Optional["MessageWriter"] = None):
|
|
32
|
+
self.config = config
|
|
33
|
+
if msg_writer is not None:
|
|
34
|
+
from .message_store import MessageStore
|
|
35
|
+
self._message_log = MessageStore(config.session_id, msg_writer)
|
|
36
|
+
else:
|
|
37
|
+
from .message_log import MessageLog
|
|
38
|
+
self._message_log = MessageLog()
|
|
39
|
+
self.state = SessionState(config=config, message_log=self._message_log)
|
|
40
|
+
self._event_handler = event_handler
|
|
41
|
+
self._transport: Optional[SocketInitiator | SocketAcceptor] = None
|
|
42
|
+
self._app: Optional[FixApplication] = None
|
|
43
|
+
self._fixcore_session_id: Optional[SessionID] = None
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Public API
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def start(self) -> None:
|
|
50
|
+
if self.state.status != SessionStatus.STOPPED:
|
|
51
|
+
raise RuntimeError(f"Session {self.config.session_id!r} is already running")
|
|
52
|
+
|
|
53
|
+
self._app = FixApplication(self.config.session_id, self._handle_event)
|
|
54
|
+
|
|
55
|
+
store_path = os.path.abspath(
|
|
56
|
+
os.path.join(self.config.file_store_path, self.config.session_id)
|
|
57
|
+
)
|
|
58
|
+
os.makedirs(store_path, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
store_factory = FileStoreFactory(store_path)
|
|
61
|
+
|
|
62
|
+
if self.config.use_file_log:
|
|
63
|
+
log_path = os.path.abspath(
|
|
64
|
+
os.path.join(self.config.file_log_path, self.config.session_id)
|
|
65
|
+
)
|
|
66
|
+
os.makedirs(log_path, exist_ok=True)
|
|
67
|
+
log_factory = FileLogFactory(log_path)
|
|
68
|
+
else:
|
|
69
|
+
log_factory = ScreenLogFactory()
|
|
70
|
+
|
|
71
|
+
settings = self._build_settings()
|
|
72
|
+
|
|
73
|
+
if self.config.connection_type == ConnectionType.INITIATOR:
|
|
74
|
+
self._transport = SocketInitiator(settings, self._app, store_factory, log_factory)
|
|
75
|
+
self.state.status = SessionStatus.CONNECTING
|
|
76
|
+
else:
|
|
77
|
+
self._transport = SocketAcceptor(settings, self._app, store_factory, log_factory)
|
|
78
|
+
self.state.status = SessionStatus.LISTENING
|
|
79
|
+
|
|
80
|
+
self.state.status_changed_at = time.time()
|
|
81
|
+
|
|
82
|
+
# Store the SessionID for later use (send, seqnums)
|
|
83
|
+
self._fixcore_session_id = list(settings.session_ids)[0]
|
|
84
|
+
|
|
85
|
+
# Start transport tasks on the current event loop
|
|
86
|
+
try:
|
|
87
|
+
await self._transport.start()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
self.state.status = SessionStatus.ERROR
|
|
90
|
+
self.state.error_message = str(e)
|
|
91
|
+
self._event_handler(SessionEvent(
|
|
92
|
+
event_type=EventType.ENGINE_ERROR,
|
|
93
|
+
session_id=self.config.session_id,
|
|
94
|
+
payload={"error": str(e)},
|
|
95
|
+
))
|
|
96
|
+
|
|
97
|
+
async def stop(self, force: bool = False) -> None:
|
|
98
|
+
if self._transport is not None:
|
|
99
|
+
try:
|
|
100
|
+
await self._transport.stop()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
self.state.status = SessionStatus.STOPPED
|
|
104
|
+
self.state.status_changed_at = time.time()
|
|
105
|
+
self._event_handler(SessionEvent(
|
|
106
|
+
event_type=EventType.SESSION_STATUS_CHANGED,
|
|
107
|
+
session_id=self.config.session_id,
|
|
108
|
+
payload={
|
|
109
|
+
"status": "STOPPED",
|
|
110
|
+
"status_changed_at": self.state.status_changed_at,
|
|
111
|
+
"last_heartbeat_at": self.state.last_heartbeat_at,
|
|
112
|
+
},
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
async def send_message(self, message: Message) -> bool:
|
|
116
|
+
"""Send an application-level FIX message. Returns False if not logged on."""
|
|
117
|
+
if self._transport is None or self._fixcore_session_id is None:
|
|
118
|
+
return False
|
|
119
|
+
transport_session = self._transport.sessions.get(self._fixcore_session_id)
|
|
120
|
+
if transport_session is None or not transport_session.is_logged_on:
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
await transport_session.send_app(message)
|
|
124
|
+
return True
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Sequence number management
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def get_seqnums(self) -> tuple:
|
|
133
|
+
"""Return (next_sender, next_target) from the FileStore."""
|
|
134
|
+
store = self._get_store()
|
|
135
|
+
if store:
|
|
136
|
+
return store.next_sender_msg_seq_num(), store.next_target_msg_seq_num()
|
|
137
|
+
return 1, 1
|
|
138
|
+
|
|
139
|
+
def set_seqnums(self, sender, target) -> tuple:
|
|
140
|
+
"""Set one or both seqnums. Works on stopped and running sessions."""
|
|
141
|
+
store = self._get_store()
|
|
142
|
+
if store is None:
|
|
143
|
+
return 1, 1
|
|
144
|
+
|
|
145
|
+
cur_sender = store.next_sender_msg_seq_num()
|
|
146
|
+
cur_target = store.next_target_msg_seq_num()
|
|
147
|
+
new_sender = sender if sender is not None else cur_sender
|
|
148
|
+
new_target = target if target is not None else cur_target
|
|
149
|
+
|
|
150
|
+
if sender is not None:
|
|
151
|
+
store.set_next_sender_msg_seq_num(new_sender)
|
|
152
|
+
if target is not None:
|
|
153
|
+
store.set_next_target_msg_seq_num(new_target)
|
|
154
|
+
|
|
155
|
+
self.state.last_sent_seq_num = new_sender - 1
|
|
156
|
+
self.state.last_recv_seq_num = new_target - 1
|
|
157
|
+
return new_sender, new_target
|
|
158
|
+
|
|
159
|
+
def _get_store(self):
|
|
160
|
+
"""Return the live MessageStore if available, else None."""
|
|
161
|
+
if self._transport is None or self._fixcore_session_id is None:
|
|
162
|
+
return None
|
|
163
|
+
transport_session = self._transport.sessions.get(self._fixcore_session_id)
|
|
164
|
+
if transport_session is not None:
|
|
165
|
+
return transport_session._store
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Internal event handling
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def _handle_event(self, event: SessionEvent) -> None:
|
|
173
|
+
"""Update local state then forward to the session manager's handler."""
|
|
174
|
+
if event.event_type == EventType.SESSION_STATUS_CHANGED:
|
|
175
|
+
status_str = event.payload.get("status", "")
|
|
176
|
+
if status_str == "LOGGED_ON":
|
|
177
|
+
self.state.status = SessionStatus.LOGGED_ON
|
|
178
|
+
self.state.status_changed_at = time.time()
|
|
179
|
+
elif status_str == "LOGGED_OUT":
|
|
180
|
+
if self.state.status != SessionStatus.STOPPED:
|
|
181
|
+
if self.config.connection_type == ConnectionType.INITIATOR:
|
|
182
|
+
self.state.status = SessionStatus.CONNECTING
|
|
183
|
+
else:
|
|
184
|
+
self.state.status = SessionStatus.LISTENING
|
|
185
|
+
self.state.status_changed_at = time.time()
|
|
186
|
+
event.payload["status"] = self.state.status.name
|
|
187
|
+
event.payload["status_changed_at"] = self.state.status_changed_at
|
|
188
|
+
event.payload["last_heartbeat_at"] = self.state.last_heartbeat_at
|
|
189
|
+
|
|
190
|
+
elif event.event_type in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
|
|
191
|
+
direction = "IN" if event.event_type == EventType.MESSAGE_RECEIVED else "OUT"
|
|
192
|
+
entry = self._message_log.add(
|
|
193
|
+
direction=direction,
|
|
194
|
+
admin=event.payload.get("admin", False),
|
|
195
|
+
raw=event.payload.get("raw", ""),
|
|
196
|
+
)
|
|
197
|
+
event.payload["seq_num"] = entry.seq_num
|
|
198
|
+
event.payload["msg_type"] = entry.msg_type
|
|
199
|
+
event.payload["msg_type_name"] = entry.msg_type_name
|
|
200
|
+
event.payload["fields"] = entry.fields
|
|
201
|
+
if direction == "OUT":
|
|
202
|
+
self.state.last_sent_seq_num = entry.seq_num
|
|
203
|
+
else:
|
|
204
|
+
self.state.last_recv_seq_num = entry.seq_num
|
|
205
|
+
if entry.msg_type == "0": # Heartbeat
|
|
206
|
+
self.state.last_heartbeat_at = time.time()
|
|
207
|
+
if (entry.msg_type == "D" and not entry.admin
|
|
208
|
+
and self.config.session_role.value == "venue"):
|
|
209
|
+
self._schedule_venue_responses(entry.fields)
|
|
210
|
+
|
|
211
|
+
self._event_handler(event)
|
|
212
|
+
|
|
213
|
+
def _schedule_venue_responses(self, order_fields: dict) -> None:
|
|
214
|
+
from .venue_responses import build_ack, build_fill, new_order_id
|
|
215
|
+
order_id = new_order_id()
|
|
216
|
+
|
|
217
|
+
async def _delayed(msg: Message, delay: float) -> None:
|
|
218
|
+
await asyncio.sleep(delay)
|
|
219
|
+
await self.send_message(msg)
|
|
220
|
+
|
|
221
|
+
if self.config.auto_ack:
|
|
222
|
+
asyncio.create_task(_delayed(build_ack(order_fields, order_id),
|
|
223
|
+
self.config.auto_ack_delay_ms / 1000.0))
|
|
224
|
+
if self.config.auto_fill:
|
|
225
|
+
asyncio.create_task(_delayed(build_fill(order_fields, order_id),
|
|
226
|
+
self.config.auto_fill_delay_ms / 1000.0))
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Settings builder
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _build_settings(self) -> SessionSettings:
|
|
233
|
+
"""Build fixcore SessionSettings from SessionConfig."""
|
|
234
|
+
ct = "initiator" if self.config.connection_type == ConnectionType.INITIATOR else "acceptor"
|
|
235
|
+
dd_path = self._resolve_data_dictionary()
|
|
236
|
+
|
|
237
|
+
store_path = os.path.abspath(
|
|
238
|
+
os.path.join(self.config.file_store_path, self.config.session_id)
|
|
239
|
+
)
|
|
240
|
+
log_path = os.path.abspath(
|
|
241
|
+
os.path.join(self.config.file_log_path, self.config.session_id)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
lines = [
|
|
245
|
+
"[DEFAULT]",
|
|
246
|
+
f"ConnectionType={ct}",
|
|
247
|
+
f"BeginString={self.config.begin_string}",
|
|
248
|
+
f"SenderCompID={self.config.sender_comp_id}",
|
|
249
|
+
f"TargetCompID={self.config.target_comp_id}",
|
|
250
|
+
f"HeartBtInt={self.config.heartbeat_interval}",
|
|
251
|
+
f"StartTime={self.config.start_time}",
|
|
252
|
+
f"EndTime={self.config.end_time}",
|
|
253
|
+
f"DataDictionary={dd_path}",
|
|
254
|
+
f"FileStorePath={store_path}",
|
|
255
|
+
f"FileLogPath={log_path}",
|
|
256
|
+
f"ResetOnLogon={'Y' if self.config.reset_on_logon else 'N'}",
|
|
257
|
+
f"ResetOnLogout={'Y' if self.config.reset_on_logout else 'N'}",
|
|
258
|
+
"",
|
|
259
|
+
"[SESSION]",
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
if self.config.connection_type == ConnectionType.INITIATOR:
|
|
263
|
+
lines += [
|
|
264
|
+
f"SocketConnectHost={self.config.host}",
|
|
265
|
+
f"SocketConnectPort={self.config.port}",
|
|
266
|
+
f"ReconnectInterval={self.config.reconnect_interval}",
|
|
267
|
+
]
|
|
268
|
+
else:
|
|
269
|
+
lines.append(f"SocketAcceptPort={self.config.port}")
|
|
270
|
+
|
|
271
|
+
return SessionSettings.from_string("\n".join(lines))
|
|
272
|
+
|
|
273
|
+
def _resolve_data_dictionary(self) -> str:
|
|
274
|
+
if self.config.data_dictionary_path:
|
|
275
|
+
return os.path.abspath(self.config.data_dictionary_path)
|
|
276
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
277
|
+
fname = "FIX42.xml" if "4.2" in self.config.begin_string else "FIX44.xml"
|
|
278
|
+
return os.path.abspath(os.path.join(here, "..", "fix_specs", fname))
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
from .models import SessionConfig, SessionState, SessionStatus
|
|
3
|
+
from .session import Session
|
|
4
|
+
from .events import EventHandler, SessionEvent
|
|
5
|
+
from .config_store import ConfigStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionManager:
|
|
9
|
+
"""
|
|
10
|
+
The sole public facade for all session operations.
|
|
11
|
+
|
|
12
|
+
All session lifecycle methods (start/stop/send) are async — they run on
|
|
13
|
+
the same asyncio event loop as FastAPI and fixcore transports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config_store: Optional[ConfigStore] = None,
|
|
17
|
+
msg_db_path: Optional[str] = None):
|
|
18
|
+
self._sessions: Dict[str, Session] = {}
|
|
19
|
+
self._handlers: List[EventHandler] = []
|
|
20
|
+
self._store = config_store
|
|
21
|
+
self._message_writer = None
|
|
22
|
+
if msg_db_path:
|
|
23
|
+
from .message_store import MessageWriter
|
|
24
|
+
self._message_writer = MessageWriter(msg_db_path)
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
# Session lifecycle
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def load_persisted(self) -> None:
|
|
31
|
+
"""Load and register sessions from the config store (does not start engines)."""
|
|
32
|
+
if self._store is None:
|
|
33
|
+
return
|
|
34
|
+
for config in self._store.load_all():
|
|
35
|
+
try:
|
|
36
|
+
self.add_session(config)
|
|
37
|
+
except (ValueError, KeyError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def add_session(self, config: SessionConfig) -> str:
|
|
41
|
+
"""Register a session. Does NOT start the engine. Returns session_id."""
|
|
42
|
+
if config.session_id in self._sessions:
|
|
43
|
+
raise ValueError(f"Session {config.session_id!r} already exists")
|
|
44
|
+
self._validate_config(config)
|
|
45
|
+
session = Session(config, self._dispatch_event, self._message_writer)
|
|
46
|
+
self._sessions[config.session_id] = session
|
|
47
|
+
self._persist()
|
|
48
|
+
return config.session_id
|
|
49
|
+
|
|
50
|
+
async def start_session(self, session_id: str) -> None:
|
|
51
|
+
await self._get(session_id).start()
|
|
52
|
+
|
|
53
|
+
async def stop_session(self, session_id: str, force: bool = False) -> None:
|
|
54
|
+
await self._get(session_id).stop(force=force)
|
|
55
|
+
|
|
56
|
+
def update_session(self, session_id: str, req) -> SessionConfig:
|
|
57
|
+
"""Update config fields on a stopped session."""
|
|
58
|
+
from .models import ConnectionType, SessionRole
|
|
59
|
+
session = self._get(session_id)
|
|
60
|
+
if session.state.status != SessionStatus.STOPPED:
|
|
61
|
+
raise RuntimeError("Session must be stopped before editing")
|
|
62
|
+
cfg = session.config
|
|
63
|
+
updates = {k: v for k, v in req.model_dump().items() if v is not None}
|
|
64
|
+
if "connection_type" in updates:
|
|
65
|
+
updates["connection_type"] = ConnectionType(updates["connection_type"])
|
|
66
|
+
if "session_role" in updates:
|
|
67
|
+
updates["session_role"] = SessionRole(updates["session_role"])
|
|
68
|
+
for key, value in updates.items():
|
|
69
|
+
setattr(cfg, key, value)
|
|
70
|
+
self._validate_config(cfg, exclude_id=session_id)
|
|
71
|
+
self._persist()
|
|
72
|
+
return cfg
|
|
73
|
+
|
|
74
|
+
async def remove_session(self, session_id: str) -> None:
|
|
75
|
+
"""Force-stop and remove a session."""
|
|
76
|
+
session = self._get(session_id)
|
|
77
|
+
await session.stop(force=True)
|
|
78
|
+
del self._sessions[session_id]
|
|
79
|
+
self._persist()
|
|
80
|
+
|
|
81
|
+
def get_state(self, session_id: str) -> SessionState:
|
|
82
|
+
return self._get(session_id).state
|
|
83
|
+
|
|
84
|
+
def list_sessions(self) -> List[SessionConfig]:
|
|
85
|
+
return [s.config for s in self._sessions.values()]
|
|
86
|
+
|
|
87
|
+
def list_sessions_for_user(self, uid: str, is_admin: bool = False) -> List[SessionConfig]:
|
|
88
|
+
configs = list(self._sessions.values())
|
|
89
|
+
if is_admin:
|
|
90
|
+
return [s.config for s in configs]
|
|
91
|
+
return [s.config for s in configs if s.config.owner_uid == uid]
|
|
92
|
+
|
|
93
|
+
def is_running(self, session_id: str) -> bool:
|
|
94
|
+
state = self.get_state(session_id)
|
|
95
|
+
return state.status not in (SessionStatus.STOPPED, SessionStatus.ERROR)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Message sending / seqnums
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def get_seqnums(self, session_id: str) -> tuple:
|
|
102
|
+
return self._get(session_id).get_seqnums()
|
|
103
|
+
|
|
104
|
+
def set_seqnums(self, session_id: str, sender, target) -> tuple:
|
|
105
|
+
return self._get(session_id).set_seqnums(sender, target)
|
|
106
|
+
|
|
107
|
+
async def send_message(self, session_id: str, message) -> bool:
|
|
108
|
+
return await self._get(session_id).send_message(message)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Event subscription
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
if self._message_writer is not None:
|
|
116
|
+
self._message_writer.close()
|
|
117
|
+
|
|
118
|
+
def subscribe(self, handler: EventHandler) -> None:
|
|
119
|
+
self._handlers.append(handler)
|
|
120
|
+
|
|
121
|
+
def unsubscribe(self, handler: EventHandler) -> None:
|
|
122
|
+
if handler in self._handlers:
|
|
123
|
+
self._handlers.remove(handler)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Internal
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _get(self, session_id: str) -> Session:
|
|
130
|
+
session = self._sessions.get(session_id)
|
|
131
|
+
if session is None:
|
|
132
|
+
raise KeyError(f"Unknown session: {session_id!r}")
|
|
133
|
+
return session
|
|
134
|
+
|
|
135
|
+
def _validate_config(self, config: SessionConfig, exclude_id: Optional[str] = None) -> None:
|
|
136
|
+
if not config.sender_comp_id:
|
|
137
|
+
raise ValueError("sender_comp_id is required")
|
|
138
|
+
if not config.target_comp_id:
|
|
139
|
+
raise ValueError("target_comp_id is required")
|
|
140
|
+
if config.begin_string not in ("FIX.4.2", "FIX.4.4"):
|
|
141
|
+
raise ValueError(f"Unsupported FIX version: {config.begin_string!r}")
|
|
142
|
+
from .models import ConnectionType
|
|
143
|
+
if config.connection_type == ConnectionType.ACCEPTOR:
|
|
144
|
+
for sid, s in self._sessions.items():
|
|
145
|
+
if sid == exclude_id:
|
|
146
|
+
continue
|
|
147
|
+
if (s.config.connection_type == ConnectionType.ACCEPTOR
|
|
148
|
+
and s.config.port == config.port):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Port {config.port} already in use by acceptor session {sid!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _persist(self) -> None:
|
|
154
|
+
if self._store is None:
|
|
155
|
+
return
|
|
156
|
+
configs = [s.config for s in self._sessions.values()]
|
|
157
|
+
by_user: dict = {}
|
|
158
|
+
for cfg in configs:
|
|
159
|
+
if cfg.owner_uid:
|
|
160
|
+
by_user.setdefault(cfg.owner_uid, []).append(cfg)
|
|
161
|
+
for uid, user_configs in by_user.items():
|
|
162
|
+
self._store.save_for_user(uid, user_configs)
|
|
163
|
+
|
|
164
|
+
def _dispatch_event(self, event: SessionEvent) -> None:
|
|
165
|
+
handlers = list(self._handlers)
|
|
166
|
+
session = self._sessions.get(event.session_id)
|
|
167
|
+
if session:
|
|
168
|
+
event.owner_uid = session.config.owner_uid
|
|
169
|
+
for handler in handlers:
|
|
170
|
+
try:
|
|
171
|
+
handler(event)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-user FIX message template storage.
|
|
3
|
+
Templates are persisted as data/<uid>/templates.json — same directory structure
|
|
4
|
+
as config_store.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TemplateStore:
|
|
16
|
+
def __init__(self, data_dir: str):
|
|
17
|
+
self._data_dir = os.path.abspath(data_dir)
|
|
18
|
+
|
|
19
|
+
def _path(self, uid: str) -> str:
|
|
20
|
+
return os.path.join(self._data_dir, uid, "templates.json")
|
|
21
|
+
|
|
22
|
+
def _load(self, uid: str) -> list[dict]:
|
|
23
|
+
path = self._path(uid)
|
|
24
|
+
if not os.path.isfile(path):
|
|
25
|
+
return []
|
|
26
|
+
try:
|
|
27
|
+
with open(path) as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
def _save(self, uid: str, templates: list[dict]) -> None:
|
|
33
|
+
user_dir = os.path.join(self._data_dir, uid)
|
|
34
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
35
|
+
path = self._path(uid)
|
|
36
|
+
tmp = path + ".tmp"
|
|
37
|
+
with open(tmp, "w") as f:
|
|
38
|
+
json.dump(templates, f, indent=2)
|
|
39
|
+
os.replace(tmp, path)
|
|
40
|
+
|
|
41
|
+
def list_templates(self, uid: str) -> list[dict]:
|
|
42
|
+
return self._load(uid)
|
|
43
|
+
|
|
44
|
+
def get_template(self, uid: str, template_id: str) -> Optional[dict]:
|
|
45
|
+
return next((t for t in self._load(uid) if t.get("id") == template_id), None)
|
|
46
|
+
|
|
47
|
+
def create_template(self, uid: str, data: dict) -> dict:
|
|
48
|
+
templates = self._load(uid)
|
|
49
|
+
template = {**data, "id": str(uuid.uuid4())}
|
|
50
|
+
templates.append(template)
|
|
51
|
+
self._save(uid, templates)
|
|
52
|
+
return template
|
|
53
|
+
|
|
54
|
+
def update_template(self, uid: str, template_id: str, data: dict) -> Optional[dict]:
|
|
55
|
+
templates = self._load(uid)
|
|
56
|
+
for i, t in enumerate(templates):
|
|
57
|
+
if t.get("id") == template_id:
|
|
58
|
+
updated = {**data, "id": template_id}
|
|
59
|
+
templates[i] = updated
|
|
60
|
+
self._save(uid, templates)
|
|
61
|
+
return updated
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def delete_template(self, uid: str, template_id: str) -> bool:
|
|
65
|
+
templates = self._load(uid)
|
|
66
|
+
new = [t for t in templates if t.get("id") != template_id]
|
|
67
|
+
if len(new) == len(templates):
|
|
68
|
+
return False
|
|
69
|
+
self._save(uid, new)
|
|
70
|
+
return True
|