fixcore-engine 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {fixcore_engine-0.1.0/fixcore_engine.egg-info → fixcore_engine-0.1.2}/PKG-INFO +1 -1
  2. fixcore_engine-0.1.2/fixcore/gui.py +517 -0
  3. fixcore_engine-0.1.2/fixcore/gui_ui/app.js +659 -0
  4. fixcore_engine-0.1.2/fixcore/gui_ui/fixcore_logo.svg +27 -0
  5. fixcore_engine-0.1.2/fixcore/gui_ui/index.html +180 -0
  6. fixcore_engine-0.1.2/fixcore/gui_ui/style.css +472 -0
  7. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/initiator.py +14 -1
  8. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2/fixcore_engine.egg-info}/PKG-INFO +1 -1
  9. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/SOURCES.txt +6 -0
  10. fixcore_engine-0.1.2/fixcore_engine.egg-info/entry_points.txt +2 -0
  11. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/pyproject.toml +7 -1
  12. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/LICENSE +0 -0
  13. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/README.md +0 -0
  14. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/__init__.py +0 -0
  15. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/application.py +0 -0
  16. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/__init__.py +0 -0
  17. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/base.py +0 -0
  18. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/factory.py +0 -0
  19. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/file_log.py +0 -0
  20. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/screen.py +0 -0
  21. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/__init__.py +0 -0
  22. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/cracker.py +0 -0
  23. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/data_dictionary.py +0 -0
  24. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/exceptions.py +0 -0
  25. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/field.py +0 -0
  26. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/message.py +0 -0
  27. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/__init__.py +0 -0
  28. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session.py +0 -0
  29. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session_id.py +0 -0
  30. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session_settings.py +0 -0
  31. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/state.py +0 -0
  32. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/__init__.py +0 -0
  33. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/base.py +0 -0
  34. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/factory.py +0 -0
  35. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/file_store.py +0 -0
  36. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/memory.py +0 -0
  37. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/__init__.py +0 -0
  38. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/acceptor.py +0 -0
  39. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/framer.py +0 -0
  40. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/dependency_links.txt +0 -0
  41. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/requires.txt +0 -0
  42. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/top_level.txt +0 -0
  43. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/setup.cfg +0 -0
  44. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_cracker.py +0 -0
  45. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_data_dictionary.py +0 -0
  46. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_file_log.py +0 -0
  47. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_file_store.py +0 -0
  48. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_framer.py +0 -0
  49. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_integration.py +0 -0
  50. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_message.py +0 -0
  51. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_session.py +0 -0
  52. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_session_id.py +0 -0
  53. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_store.py +0 -0
  54. {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixcore-engine
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Pure Python FIX protocol engine
5
5
  Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
6
  License: MIT License
@@ -0,0 +1,517 @@
1
+ """FIX Engine GUI — aiohttp backend + browser frontend.
2
+
3
+ Starts a local HTTP server, opens the default browser, and drives the
4
+ FIX engine from the same asyncio event loop (no threading needed).
5
+
6
+ Run:
7
+ fixcore-gui
8
+ fixcore-gui --port 8765
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ import webbrowser
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ try:
22
+ from aiohttp import web
23
+ except ImportError:
24
+ print("aiohttp is required: pip install \"fixcore-engine[gui]\"")
25
+ sys.exit(1)
26
+
27
+ from fixcore.application import Application
28
+ from fixcore.log.base import Log, LogFactory
29
+ from fixcore.message.message import Message
30
+ from fixcore.session.session_id import SessionID
31
+ from fixcore.session.session_settings import SessionSettings
32
+ from fixcore.store.factory import FileStoreFactory, MemoryStoreFactory
33
+ from fixcore.transport.acceptor import SocketAcceptor
34
+ from fixcore.transport.initiator import SocketInitiator
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # WebSocket connections — used to push engine events to the browser
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _ws_connections: set[web.WebSocketResponse] = set()
41
+
42
+
43
+ def _push_event(event_type: str, **kwargs: Any) -> None:
44
+ """Push a JSON event to all connected browser tabs."""
45
+ if not _ws_connections:
46
+ return
47
+ payload = json.dumps({"type": event_type, **kwargs})
48
+ try:
49
+ loop = asyncio.get_running_loop()
50
+ except RuntimeError:
51
+ return
52
+ for ws in list(_ws_connections):
53
+ loop.create_task(ws.send_str(payload))
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Bridge: Application + Log callbacks → browser via WebSocket
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _sid_key(sid: SessionID) -> str:
61
+ return f"{sid.sender_comp_id}/{sid.target_comp_id}/{sid.begin_string}"
62
+
63
+
64
+ class GUIApplication(Application):
65
+ def on_create(self, sid: SessionID) -> None:
66
+ pass
67
+
68
+ def on_logon(self, sid: SessionID) -> None:
69
+ _push_event("logon", sid=_sid_key(sid))
70
+
71
+ def on_logout(self, sid: SessionID) -> None:
72
+ _push_event("logout", sid=_sid_key(sid))
73
+
74
+ def to_admin(self, msg: Message, sid: SessionID) -> None:
75
+ pass
76
+
77
+ def to_app(self, msg: Message, sid: SessionID) -> None:
78
+ pass
79
+
80
+ def from_admin(self, msg: Message, sid: SessionID) -> None:
81
+ pass
82
+
83
+ def from_app(self, msg: Message, sid: SessionID) -> None:
84
+ _push_event("msg_in", sid=_sid_key(sid), msg_type=msg.msg_type)
85
+
86
+
87
+ class GUILog(Log):
88
+ def __init__(self, sid: SessionID) -> None:
89
+ self._sid = _sid_key(sid)
90
+
91
+ def on_incoming(self, raw: str) -> None:
92
+ _push_event("raw_in", sid=self._sid, raw=raw.replace("\x01", "|"))
93
+
94
+ def on_outgoing(self, raw: str) -> None:
95
+ _push_event("raw_out", sid=self._sid, raw=raw.replace("\x01", "|"))
96
+
97
+ def on_event(self, text: str) -> None:
98
+ _push_event("session_event", sid=self._sid, text=text)
99
+
100
+
101
+ class GUILogFactory(LogFactory):
102
+ def create(self, sid: SessionID) -> Log:
103
+ return GUILog(sid)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Session registry
108
+ # ---------------------------------------------------------------------------
109
+
110
+ _sessions: dict[str, dict[str, Any]] = {}
111
+
112
+
113
+ def _build_cfg(config: dict[str, Any]) -> str:
114
+ conn = config.get("connection_type", "initiator")
115
+ lines = [
116
+ "[DEFAULT]",
117
+ f"ConnectionType={conn}",
118
+ f"HeartBtInt={config.get('heartbt_int', 30)}",
119
+ "ReconnectInterval=5",
120
+ "",
121
+ "[SESSION]",
122
+ f"BeginString={config.get('begin_string', 'FIX.4.2')}",
123
+ f"SenderCompID={config['sender_comp_id']}",
124
+ f"TargetCompID={config['target_comp_id']}",
125
+ ]
126
+ if conn == "initiator":
127
+ lines += [
128
+ f"SocketConnectHost={config.get('host', '127.0.0.1')}",
129
+ f"SocketConnectPort={config.get('port', 9878)}",
130
+ ]
131
+ else:
132
+ lines += [
133
+ f"SocketAcceptHost={config.get('host', '0.0.0.0')}",
134
+ f"SocketAcceptPort={config.get('port', 9878)}",
135
+ ]
136
+ return "\n".join(lines)
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # HTTP / WebSocket handlers
141
+ # ---------------------------------------------------------------------------
142
+
143
+ GUI_DIR = Path(__file__).parent / "gui_ui"
144
+ SESSIONS_FILE = Path("fix_sessions.json")
145
+
146
+
147
+ def _open_browser(url: str) -> None:
148
+ """Open *url* in the default browser, handling WSL2 gracefully."""
149
+ try:
150
+ wsl = "microsoft" in Path("/proc/version").read_text().lower()
151
+ except OSError:
152
+ wsl = False
153
+ if wsl:
154
+ subprocess.Popen(["cmd.exe", "/c", "start", url],
155
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
156
+ else:
157
+ webbrowser.open(url)
158
+
159
+
160
+ async def handle_index(request: web.Request) -> web.FileResponse:
161
+ return web.FileResponse(GUI_DIR / "index.html")
162
+
163
+
164
+ async def handle_static(request: web.Request) -> web.FileResponse:
165
+ name = request.match_info["name"]
166
+ filepath = (GUI_DIR / name).resolve()
167
+ try:
168
+ filepath.relative_to(GUI_DIR.resolve())
169
+ except ValueError:
170
+ raise web.HTTPForbidden()
171
+ if not filepath.exists():
172
+ raise web.HTTPNotFound()
173
+ return web.FileResponse(filepath)
174
+
175
+
176
+ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
177
+ ws = web.WebSocketResponse()
178
+ await ws.prepare(request)
179
+ _ws_connections.add(ws)
180
+ try:
181
+ async for _ in ws:
182
+ pass # we only push; ignore inbound frames
183
+ finally:
184
+ _ws_connections.discard(ws)
185
+ return ws
186
+
187
+
188
+ async def handle_api(request: web.Request) -> web.Response:
189
+ method = request.match_info["method"]
190
+ body: dict[str, Any] = {}
191
+ if request.content_length:
192
+ try:
193
+ body = await request.json()
194
+ except Exception:
195
+ pass
196
+ result = await _dispatch(method, body)
197
+ return web.json_response(result)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # API dispatch
202
+ # ---------------------------------------------------------------------------
203
+
204
+ async def _dispatch(method: str, body: dict[str, Any]) -> Any:
205
+ if method == "get_version":
206
+ return "fixcore 0.1"
207
+ if method == "get_sessions":
208
+ return _get_sessions()
209
+ if method == "create_session":
210
+ return await _create_session(body)
211
+ if method == "remove_session":
212
+ return await _remove_session(body.get("sid_key", ""))
213
+ if method == "logon":
214
+ return await _logon(body.get("sid_key", ""))
215
+ if method == "logout":
216
+ return await _logout(body.get("sid_key", ""))
217
+ if method == "reset_seq_nums":
218
+ return _reset_seq_nums(body.get("sid_key", ""))
219
+ if method == "send_message":
220
+ return await _send_message(
221
+ body.get("sid_key", ""),
222
+ body.get("msg_type", ""),
223
+ body.get("fields", []),
224
+ )
225
+ if method == "update_session":
226
+ return await _update_session(body.get("old_key", ""), body)
227
+ if method == "stop":
228
+ return await _stop(body.get("sid_key", ""))
229
+ return {"ok": False, "error": f"Unknown method: {method!r}"}
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Business logic
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def _save_sessions_config() -> None:
237
+ """Persist all current session configs to SESSIONS_FILE."""
238
+ configs = [entry["config"] for entry in _sessions.values()]
239
+ SESSIONS_FILE.write_text(json.dumps(configs, indent=2))
240
+
241
+
242
+ async def _restore_sessions() -> None:
243
+ """Load and recreate sessions from SESSIONS_FILE on startup."""
244
+ if not SESSIONS_FILE.exists():
245
+ return
246
+ try:
247
+ configs = json.loads(SESSIONS_FILE.read_text())
248
+ except Exception as exc:
249
+ print(f"[warn] Could not read {SESSIONS_FILE}: {exc}")
250
+ return
251
+ for config in configs:
252
+ result = await _create_session(config)
253
+ if not result.get("ok"):
254
+ print(f"[warn] Could not restore session: {result.get('error')}")
255
+
256
+
257
+ def _get_sessions() -> list[dict[str, Any]]:
258
+ result = []
259
+ for key, entry in _sessions.items():
260
+ transport = entry.get("transport")
261
+ session = None
262
+ if transport is not None:
263
+ d = transport.sessions
264
+ if d:
265
+ session = next(iter(d.values()))
266
+ state = "DISCONNECTED"
267
+ sender_seq = 1
268
+ target_seq = 1
269
+ if session is not None:
270
+ state = session.state.name
271
+ try:
272
+ sender_seq = session._store.next_sender_msg_seq_num()
273
+ target_seq = session._store.next_target_msg_seq_num()
274
+ except Exception:
275
+ pass
276
+ cfg = entry["config"]
277
+ result.append({
278
+ "key": key,
279
+ "label": f"{cfg['sender_comp_id']} \u2192 {cfg['target_comp_id']}",
280
+ "begin_string": cfg.get("begin_string", "FIX.4.2"),
281
+ "connection_type": cfg.get("connection_type", "initiator"),
282
+ "sender_comp_id": cfg.get("sender_comp_id", ""),
283
+ "target_comp_id": cfg.get("target_comp_id", ""),
284
+ "host": cfg.get("host", ""),
285
+ "port": cfg.get("port", 0),
286
+ "heartbt_int": cfg.get("heartbt_int", 30),
287
+ "store": cfg.get("store", "memory"),
288
+ "state": state,
289
+ "sender_seq": sender_seq,
290
+ "target_seq": target_seq,
291
+ "started": entry.get("started", False),
292
+ })
293
+ return result
294
+
295
+
296
+ async def _create_session(config: dict[str, Any]) -> dict[str, Any]:
297
+ sender = config.get("sender_comp_id", "").strip()
298
+ target = config.get("target_comp_id", "").strip()
299
+ begin = config.get("begin_string", "FIX.4.2")
300
+ if not sender or not target:
301
+ return {"ok": False, "error": "SenderCompID and TargetCompID are required"}
302
+ key = f"{sender}/{target}/{begin}"
303
+ if key in _sessions:
304
+ return {"ok": False, "error": f"Session {key!r} already exists"}
305
+ try:
306
+ settings = SessionSettings.from_string(_build_cfg(config))
307
+ except Exception as exc:
308
+ return {"ok": False, "error": str(exc)}
309
+ store_type = config.get("store", "memory")
310
+ if store_type == "file":
311
+ store_dir = Path("fix_store")
312
+ store_dir.mkdir(exist_ok=True)
313
+ store_factory: Any = FileStoreFactory(store_dir)
314
+ else:
315
+ store_factory = MemoryStoreFactory()
316
+ app = GUIApplication()
317
+ log_factory = GUILogFactory()
318
+ conn = config.get("connection_type", "initiator")
319
+ transport: SocketInitiator | SocketAcceptor
320
+ if conn == "initiator":
321
+ transport = SocketInitiator(settings, app, store_factory, log_factory)
322
+ else:
323
+ transport = SocketAcceptor(settings, app, store_factory, log_factory)
324
+ _sessions[key] = {"config": config, "transport": transport, "started": False}
325
+ try:
326
+ await transport.start()
327
+ _sessions[key]["started"] = True
328
+ except Exception as exc:
329
+ del _sessions[key]
330
+ return {"ok": False, "error": f"Failed to start: {exc}"}
331
+ _save_sessions_config()
332
+ return {"ok": True, "key": key}
333
+
334
+
335
+ async def _remove_session(sid_key: str) -> dict[str, Any]:
336
+ entry = _sessions.get(sid_key)
337
+ if entry is None:
338
+ return {"ok": False, "error": "Unknown session"}
339
+ transport = entry.get("transport")
340
+ if transport and entry.get("started"):
341
+ try:
342
+ await transport.stop()
343
+ except Exception:
344
+ pass
345
+ del _sessions[sid_key]
346
+ _save_sessions_config()
347
+ return {"ok": True}
348
+
349
+
350
+ async def _update_session(old_key: str, config: dict[str, Any]) -> dict[str, Any]:
351
+ entry = _sessions.get(old_key)
352
+ if entry is None:
353
+ return {"ok": False, "error": "Unknown session"}
354
+ transport = entry.get("transport")
355
+ if transport and entry.get("started"):
356
+ try:
357
+ await transport.stop()
358
+ except Exception:
359
+ pass
360
+ del _sessions[old_key]
361
+ return await _create_session(config)
362
+
363
+
364
+ async def _logon(sid_key: str) -> dict[str, Any]:
365
+ entry = _sessions.get(sid_key)
366
+ if entry is None:
367
+ return {"ok": False, "error": "Unknown session"}
368
+ transport = entry.get("transport")
369
+ if transport is None:
370
+ return {"ok": False, "error": "No transport"}
371
+ try:
372
+ if entry.get("started"):
373
+ await transport.stop()
374
+ await transport.start()
375
+ entry["started"] = True
376
+ except Exception as exc:
377
+ return {"ok": False, "error": str(exc)}
378
+ return {"ok": True}
379
+
380
+
381
+ async def _logout(sid_key: str) -> dict[str, Any]:
382
+ """Graceful FIX logout — sends Logout message and disables auto-reconnect."""
383
+ entry = _sessions.get(sid_key)
384
+ if entry is None:
385
+ return {"ok": False, "error": "Unknown session"}
386
+ transport = entry.get("transport")
387
+ if transport is None:
388
+ return {"ok": False, "error": "No transport"}
389
+ try:
390
+ if isinstance(transport, SocketInitiator):
391
+ await transport.logout()
392
+ else:
393
+ session = next(iter(transport.sessions.values()), None)
394
+ if session and session.is_logged_on:
395
+ await session.send_logout()
396
+ entry["started"] = False
397
+ except Exception as exc:
398
+ return {"ok": False, "error": str(exc)}
399
+ return {"ok": True}
400
+
401
+
402
+ async def _stop(sid_key: str) -> dict[str, Any]:
403
+ """Hard stop — kills the transport immediately without sending FIX Logout."""
404
+ entry = _sessions.get(sid_key)
405
+ if entry is None:
406
+ return {"ok": False, "error": "Unknown session"}
407
+ transport = entry.get("transport")
408
+ if transport is None:
409
+ return {"ok": False, "error": "No transport"}
410
+ try:
411
+ await transport.stop()
412
+ entry["started"] = False
413
+ except Exception as exc:
414
+ return {"ok": False, "error": str(exc)}
415
+ return {"ok": True}
416
+
417
+
418
+ def _reset_seq_nums(sid_key: str) -> dict[str, Any]:
419
+ entry = _sessions.get(sid_key)
420
+ if entry is None:
421
+ return {"ok": False, "error": "Unknown session"}
422
+ transport = entry.get("transport")
423
+ if transport is None:
424
+ return {"ok": False, "error": "No transport"}
425
+ session = next(iter(transport.sessions.values()), None)
426
+ if session is None:
427
+ return {"ok": False, "error": "No session"}
428
+ if session.is_logged_on:
429
+ return {"ok": False, "error": "Disconnect before resetting sequence numbers"}
430
+ try:
431
+ session._store.reset()
432
+ except Exception as exc:
433
+ return {"ok": False, "error": str(exc)}
434
+ return {"ok": True}
435
+
436
+
437
+ async def _send_message(
438
+ sid_key: str, msg_type: str, fields: list[dict[str, str]]
439
+ ) -> dict[str, Any]:
440
+ entry = _sessions.get(sid_key)
441
+ if entry is None:
442
+ return {"ok": False, "error": "Unknown session"}
443
+ transport = entry.get("transport")
444
+ if transport is None:
445
+ return {"ok": False, "error": "No transport"}
446
+ session = next(iter(transport.sessions.values()), None)
447
+ if session is None or not session.is_logged_on:
448
+ return {"ok": False, "error": "Not logged on"}
449
+ msg = Message()
450
+ msg.header.set(35, msg_type)
451
+ for f in fields:
452
+ try:
453
+ tag = int(f["tag"])
454
+ val = str(f["value"])
455
+ msg.set_field(tag, val)
456
+ except (KeyError, ValueError) as exc:
457
+ return {"ok": False, "error": f"Bad field {f!r}: {exc}"}
458
+ try:
459
+ await session.send_app(msg)
460
+ except Exception as exc:
461
+ return {"ok": False, "error": str(exc)}
462
+ return {"ok": True}
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # Server startup
467
+ # ---------------------------------------------------------------------------
468
+
469
+ async def main(port: int) -> None:
470
+ app = web.Application()
471
+ app.router.add_get("/", handle_index)
472
+ app.router.add_get("/ws", handle_ws)
473
+ app.router.add_post("/api/{method}", handle_api)
474
+ app.router.add_get("/{name:.+}", handle_static)
475
+
476
+ runner = web.AppRunner(app)
477
+ await runner.setup()
478
+ site = web.TCPSite(runner, "127.0.0.1", port)
479
+ await site.start()
480
+
481
+ await _restore_sessions()
482
+
483
+ url = f"http://127.0.0.1:{port}"
484
+ print(f"FIXcore GUI running at {url} (Ctrl+C to stop)")
485
+ _open_browser(url)
486
+
487
+ try:
488
+ await asyncio.Event().wait()
489
+ except asyncio.CancelledError:
490
+ pass
491
+ finally:
492
+ for entry in list(_sessions.values()):
493
+ t = entry.get("transport")
494
+ if t and entry.get("started"):
495
+ try:
496
+ await t.stop()
497
+ except Exception:
498
+ pass
499
+ await runner.cleanup()
500
+
501
+
502
+ def run() -> None:
503
+ """Console script entry point — invoked by `fixcore-gui`."""
504
+ port = 8765
505
+ for i, arg in enumerate(sys.argv[1:], 1):
506
+ if arg == "--port" and i < len(sys.argv):
507
+ port = int(sys.argv[i + 1])
508
+ elif arg.startswith("--port="):
509
+ port = int(arg.split("=", 1)[1])
510
+ try:
511
+ asyncio.run(main(port))
512
+ except KeyboardInterrupt:
513
+ print("\nShutting down.")
514
+
515
+
516
+ if __name__ == "__main__":
517
+ run()