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,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
@@ -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