fixtureqa 0.12.1__tar.gz → 0.14.0__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 (108) hide show
  1. {fixtureqa-0.12.1/fixtureqa.egg-info → fixtureqa-0.14.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/app.py +5 -0
  3. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/connection_manager.py +17 -2
  4. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/messages.py +27 -6
  5. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/sessions.py +3 -2
  6. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/schemas.py +2 -0
  7. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/message_log.py +6 -3
  8. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/message_store.py +26 -16
  9. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/session.py +27 -4
  10. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/session_manager.py +2 -2
  11. fixtureqa-0.12.1/fixture/static/assets/index-DNW24fuM.js → fixtureqa-0.14.0/fixture/static/assets/index-Bh-VNEs4.js +22 -22
  12. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/index.html +1 -1
  13. {fixtureqa-0.12.1 → fixtureqa-0.14.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  14. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixtureqa.egg-info/SOURCES.txt +2 -1
  15. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/pyproject.toml +1 -1
  16. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_db_migrations.py +3 -1
  17. fixtureqa-0.14.0/tests/test_msg_user_attribution.py +200 -0
  18. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/LICENSE +0 -0
  19. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/README.md +0 -0
  20. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/__init__.py +0 -0
  21. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/__main__.py +0 -0
  22. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/__init__.py +0 -0
  23. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/deps.py +0 -0
  24. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/__init__.py +0 -0
  25. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/admin.py +0 -0
  26. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/auth.py +0 -0
  27. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/branding.py +0 -0
  28. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/custom_tags.py +0 -0
  29. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/fix_spec.py +0 -0
  30. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/perf.py +0 -0
  31. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/scenarios.py +0 -0
  32. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/specs.py +0 -0
  35. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/templates.py +0 -0
  36. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/api/routers/ws.py +0 -0
  37. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/config/__init__.py +0 -0
  38. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/__init__.py +0 -0
  39. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/atomic_io.py +0 -0
  40. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/auth.py +0 -0
  41. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/config_store.py +0 -0
  42. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/custom_tag_store.py +0 -0
  43. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/db_migrations.py +0 -0
  44. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/events.py +0 -0
  45. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/latency_csv_writer.py +0 -0
  55. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/models.py +0 -0
  56. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_charts.py +0 -0
  57. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_engine.py +0 -0
  58. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_models.py +0 -0
  59. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_payload.py +0 -0
  60. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_stats.py +0 -0
  61. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/scenario_runner.py +0 -0
  64. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/scenario_store.py +0 -0
  65. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/spec_overlay_store.py +0 -0
  66. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/spec_store.py +0 -0
  67. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/template_store.py +0 -0
  68. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/user_store.py +0 -0
  69. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/value_gen.py +0 -0
  70. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/core/venue_responses.py +0 -0
  71. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/server.py +0 -0
  72. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  73. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  74. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/assets/index-D0a-mw9U.css +0 -0
  75. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/setup.cfg +0 -0
  83. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_fix_builder.py +0 -0
  88. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_fix_spec_tree.py +0 -0
  89. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_health.py +0 -0
  90. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_non_stop_session.py +0 -0
  94. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_perf_api.py +0 -0
  95. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_perf_engine.py +0 -0
  96. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_perf_models.py +0 -0
  97. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_perf_payload.py +0 -0
  98. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_perf_rehydrate.py +0 -0
  99. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_scenarios.py +0 -0
  100. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_session_auto_response.py +0 -0
  101. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_session_lifecycle.py +0 -0
  102. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_session_manager_concurrency.py +0 -0
  103. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_sessions.py +0 -0
  104. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_specs.py +0 -0
  105. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_templates.py +0 -0
  106. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_timezone_data.py +0 -0
  107. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_value_gen.py +0 -0
  108. {fixtureqa-0.12.1 → fixtureqa-0.14.0}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.12.1
3
+ Version: 0.14.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -35,6 +35,11 @@ async def lifespan(app: FastAPI):
35
35
  # Register event bridge with SessionManager
36
36
  sm: SessionManager = app.state.session_manager
37
37
  cm: ConnectionManager = app.state.connection_manager
38
+ # Resolve a sender's uid → username for live message frames.
39
+ _user_store = app.state.user_store
40
+ cm.set_username_resolver(
41
+ lambda uid: (u.username if (u := _user_store.get_by_uid(uid)) else None)
42
+ )
38
43
  sm.subscribe(cm.sync_event_handler)
39
44
  # Periodic WS message-rate aggregator (emits message_agg under high load)
40
45
  agg_task = asyncio.create_task(cm.run_aggregator())
@@ -8,6 +8,7 @@ import asyncio
8
8
  import json
9
9
  import logging
10
10
  from datetime import datetime, timezone
11
+ from typing import Callable
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
@@ -45,6 +46,7 @@ def _serialize_event(event: SessionEvent) -> dict:
45
46
  "ts": _now_iso(),
46
47
  "fields": {str(k): v for k, v in p.get("fields", {}).items()},
47
48
  "raw": p.get("raw", ""),
49
+ "user_id": p.get("user_id"),
48
50
  }
49
51
  elif event.event_type == EventType.ENGINE_ERROR:
50
52
  return {
@@ -83,6 +85,9 @@ class ConnectionManager:
83
85
  # The FastAPI/uvicorn event loop, bound in the app lifespan — needed to
84
86
  # schedule broadcasts submitted from worker threads (scenario runner).
85
87
  self._loop: asyncio.AbstractEventLoop | None = None
88
+ # uid -> username resolver, bound in the app lifespan. Enriches live
89
+ # message frames so the UI shows the sender's name, not their uid.
90
+ self._username_resolver: "Callable[[str], str | None] | None" = None
86
91
  # Hold strong references to in-flight broadcast tasks. asyncio only keeps
87
92
  # a weak reference to tasks, so a fire-and-forget create_task() can be
88
93
  # garbage-collected mid-execution, silently dropping a broadcast.
@@ -91,6 +96,16 @@ class ConnectionManager:
91
96
  self._counters: dict[str, dict] = {}
92
97
  self._per_window_cap = max(1, int(self.PER_MSG_RATE_CAP * self.AGG_INTERVAL_S))
93
98
 
99
+ def set_username_resolver(self, fn: "Callable[[str], str | None]") -> None:
100
+ self._username_resolver = fn
101
+
102
+ def _frame(self, event: SessionEvent) -> dict:
103
+ """Serialize an event, resolving a message sender's uid to a username."""
104
+ frame = _serialize_event(event)
105
+ if frame.get("type") == "message" and frame.get("user_id") and self._username_resolver:
106
+ frame["username"] = self._username_resolver(frame["user_id"])
107
+ return frame
108
+
94
109
  def _has_clients(self) -> bool:
95
110
  return any(self._clients.values())
96
111
 
@@ -150,10 +165,10 @@ class ConnectionManager:
150
165
  # Stream per-message only while under the per-window cap; beyond it
151
166
  # the aggregator emits a summary instead (bounded task creation).
152
167
  if c["count"] <= self._per_window_cap:
153
- self._spawn(self._broadcast(sid, _serialize_event(event), event.owner_uid))
168
+ self._spawn(self._broadcast(sid, self._frame(event), event.owner_uid))
154
169
  else:
155
170
  # status / engine_error — always delivered immediately
156
- self._spawn(self._broadcast(event.session_id, _serialize_event(event), event.owner_uid))
171
+ self._spawn(self._broadcast(event.session_id, self._frame(event), event.owner_uid))
157
172
 
158
173
  async def run_aggregator(self) -> None:
159
174
  """Periodic task: emit a `message_agg` summary for any session whose
@@ -7,17 +7,29 @@ from fastapi.responses import StreamingResponse
7
7
  from ...core.message_store import MessageStore
8
8
  from ...core.models import SessionConfig
9
9
  from ...core.session_manager import SessionManager
10
- from ...core.user_store import User
10
+ from ...core.user_store import User, UserStore
11
11
  from ..schemas import MessageEntryResponse
12
- from ..deps import get_session_manager, get_owned_session
12
+ from ..deps import get_session_manager, get_owned_session, get_user_store
13
13
 
14
14
  router = APIRouter(tags=["messages"])
15
15
 
16
16
  SM = Annotated[SessionManager, Depends(get_session_manager)]
17
+ US = Annotated[UserStore, Depends(get_user_store)]
17
18
  OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
18
19
 
19
20
 
20
- def _to_response(e) -> MessageEntryResponse:
21
+ def _username_map(entries, users: UserStore) -> dict[str, str]:
22
+ """Resolve the distinct sender uids in `entries` to usernames in one pass."""
23
+ uids = {e.user_id for e in entries if e.user_id}
24
+ out: dict[str, str] = {}
25
+ for uid in uids:
26
+ u = users.get_by_uid(uid)
27
+ if u is not None:
28
+ out[uid] = u.username
29
+ return out
30
+
31
+
32
+ def _to_response(e, usernames: dict[str, str]) -> MessageEntryResponse:
21
33
  return MessageEntryResponse(
22
34
  id=e.id,
23
35
  direction=e.direction,
@@ -28,6 +40,8 @@ def _to_response(e) -> MessageEntryResponse:
28
40
  ts=e.timestamp.isoformat(),
29
41
  fields={str(k): v for k, v in e.fields.items()},
30
42
  raw=e.raw,
43
+ user_id=e.user_id,
44
+ username=usernames.get(e.user_id) if e.user_id else None,
31
45
  )
32
46
 
33
47
 
@@ -36,6 +50,7 @@ def get_messages(
36
50
  session_id: str,
37
51
  _cfg: OwnedSession,
38
52
  sm: SM,
53
+ users: US,
39
54
  direction: Optional[str] = Query(default=None, description="IN or OUT"),
40
55
  admin: Optional[bool] = Query(default=None),
41
56
  msg_type: Optional[str] = Query(default=None),
@@ -59,7 +74,8 @@ def get_messages(
59
74
  before_id=before_id,
60
75
  )
61
76
 
62
- return [_to_response(e) for e in entries]
77
+ usernames = _username_map(entries, users)
78
+ return [_to_response(e, usernames) for e in entries]
63
79
 
64
80
 
65
81
  @router.get("/{session_id}/messages/search", response_model=list[MessageEntryResponse])
@@ -67,6 +83,7 @@ def search_messages(
67
83
  session_id: str,
68
84
  _cfg: OwnedSession,
69
85
  sm: SM,
86
+ users: US,
70
87
  q: str = Query(..., min_length=1, description="substring or regex query over raw FIX"),
71
88
  regex: bool = Query(default=False, description="treat q as a regular expression"),
72
89
  limit: int = Query(default=1000, ge=1, le=5000),
@@ -82,7 +99,8 @@ def search_messages(
82
99
  entries = store.search_entries(q, regex=regex, limit=limit)
83
100
  except ValueError as e:
84
101
  raise HTTPException(status_code=400, detail=str(e))
85
- return [_to_response(e) for e in entries]
102
+ usernames = _username_map(entries, users)
103
+ return [_to_response(e, usernames) for e in entries]
86
104
 
87
105
 
88
106
  @router.get("/{session_id}/messages/export")
@@ -90,6 +108,7 @@ def export_messages(
90
108
  session_id: str,
91
109
  _cfg: OwnedSession,
92
110
  sm: SM,
111
+ users: US,
93
112
  fmt: str = Query(default="csv", description="Export format: csv or fix"),
94
113
  ):
95
114
  try:
@@ -99,6 +118,7 @@ def export_messages(
99
118
 
100
119
  log = state.message_log
101
120
  entries = log.get_entries() if log is not None else []
121
+ usernames = _username_map(entries, users)
102
122
 
103
123
  safe_id = session_id.replace("/", "_")
104
124
 
@@ -117,7 +137,7 @@ def export_messages(
117
137
  def generate_csv():
118
138
  buf = io.StringIO()
119
139
  writer = csv.writer(buf)
120
- writer.writerow(["id", "ts", "direction", "admin", "seq_num", "msg_type", "msg_type_name", "raw"])
140
+ writer.writerow(["id", "ts", "direction", "admin", "seq_num", "msg_type", "msg_type_name", "user", "raw"])
121
141
  yield buf.getvalue()
122
142
  for e in entries:
123
143
  buf = io.StringIO()
@@ -130,6 +150,7 @@ def export_messages(
130
150
  e.seq_num,
131
151
  e.msg_type,
132
152
  e.msg_type_name,
153
+ usernames.get(e.user_id, "") if e.user_id else "",
133
154
  e.raw.replace("\x01", "|"),
134
155
  ])
135
156
  yield buf.getvalue()
@@ -240,7 +240,8 @@ async def stop_session(
240
240
 
241
241
 
242
242
  @router.post("/{session_id}/send")
243
- async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSession, sm: SM):
243
+ async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSession,
244
+ sm: SM, user: CurrentUser):
244
245
  from ...core.fix_builder import build_message_from_raw
245
246
  from ...core.fix_time import utc_timestamp
246
247
 
@@ -252,7 +253,7 @@ async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSes
252
253
  except ValueError as e:
253
254
  raise HTTPException(status_code=422, detail=str(e))
254
255
 
255
- ok = await sm.send_message(session_id, msg)
256
+ ok = await sm.send_message(session_id, msg, user_id=user.uid)
256
257
  if not ok:
257
258
  raise HTTPException(status_code=409, detail="Session not logged on")
258
259
  return {"ok": True}
@@ -162,6 +162,8 @@ class MessageEntryResponse(BaseModel):
162
162
  ts: str
163
163
  fields: dict[str, str]
164
164
  raw: str
165
+ user_id: Optional[str] = None # uid of the sending user; None if unattributed
166
+ username: Optional[str] = None # resolved display name for user_id
165
167
 
166
168
 
167
169
  class SendMessageRequest(BaseModel):
@@ -24,6 +24,7 @@ class LogEntry:
24
24
  msg_type_name: str # human-readable, e.g. "NewOrderSingle"
25
25
  fields: dict = field(default_factory=dict) # {tag_int: value_str}
26
26
  id: Optional[int] = None # SQLite rowid; None for in-memory entries
27
+ user_id: Optional[str] = None # uid of the sending user; None for inbound/engine sends
27
28
 
28
29
  def pretty(self, use_names: bool = True) -> str:
29
30
  """Multi-line human-readable display."""
@@ -58,11 +59,12 @@ class MessageLog:
58
59
  # Write
59
60
  # ------------------------------------------------------------------
60
61
 
61
- def add(self, direction: str, admin: bool, raw: str) -> LogEntry:
62
+ def add(self, direction: str, admin: bool, raw: str, user_id: Optional[str] = None) -> LogEntry:
62
63
  """Parse and store a message. Returns the created LogEntry."""
63
- return self.add_entry(direction, admin, raw, parse_raw(raw))
64
+ return self.add_entry(direction, admin, raw, parse_raw(raw), user_id=user_id)
64
65
 
65
- def add_entry(self, direction: str, admin: bool, raw: str, fields: dict) -> LogEntry:
66
+ def add_entry(self, direction: str, admin: bool, raw: str, fields: dict,
67
+ user_id: Optional[str] = None) -> LogEntry:
66
68
  """Store a LogEntry from pre-parsed fields (no re-parse). Returns it."""
67
69
  mt = get_msg_type(fields)
68
70
  entry = LogEntry(
@@ -74,6 +76,7 @@ class MessageLog:
74
76
  msg_type=mt,
75
77
  msg_type_name=msg_type_name(mt) if mt else "Unknown",
76
78
  fields=fields,
79
+ user_id=user_id,
77
80
  )
78
81
  with self._lock:
79
82
  if len(self._entries) >= self._max:
@@ -27,8 +27,14 @@ from .message_log import LogEntry
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
+ def _migrate_to_2(conn: sqlite3.Connection) -> None:
31
+ """v2: attribute each row to the user who sent it (NULL for inbound and
32
+ engine-originated sends). Existing rows backfill to NULL."""
33
+ conn.execute("ALTER TABLE messages ADD COLUMN user_id TEXT")
34
+
35
+
30
36
  # Schema migrations keyed by target version (>1). Empty = baseline v1 only.
31
- _MIGRATIONS: dict = {}
37
+ _MIGRATIONS: dict = {2: _migrate_to_2}
32
38
 
33
39
  # Bound on the writer queue. Inserts past this are dropped (and counted) rather
34
40
  # than growing memory without limit under sustained overload. Generous enough
@@ -55,15 +61,22 @@ CREATE TABLE IF NOT EXISTS messages (
55
61
  fields_json TEXT NOT NULL
56
62
  )
57
63
  """
64
+ # NOTE: user_id is intentionally NOT here — it's added by the v2 migration so
65
+ # fresh and pre-existing DBs converge on the same path (CREATE TABLE stays at
66
+ # the v1 baseline per the db_migrations contract).
58
67
  _CREATE_INDEX = """
59
68
  CREATE INDEX IF NOT EXISTS idx_msg_session ON messages(session_id, id DESC)
60
69
  """
61
70
  _INSERT = """
62
71
  INSERT INTO messages
63
- (session_id, ts, direction, admin, seq_num, msg_type, msg_type_name, raw, fields_json)
64
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
72
+ (session_id, ts, direction, admin, seq_num, msg_type, msg_type_name, raw, fields_json, user_id)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
65
74
  """
66
75
 
76
+ # Column list shared by the row-reading SELECTs, kept in sync with _row_to_entry.
77
+ _COLUMNS = ("id, session_id, ts, direction, admin, seq_num, "
78
+ "msg_type, msg_type_name, raw, fields_json, user_id")
79
+
67
80
 
68
81
  def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
69
82
  return (
@@ -76,6 +89,7 @@ def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
76
89
  entry.msg_type_name,
77
90
  entry.raw,
78
91
  json.dumps(entry.fields),
92
+ entry.user_id,
79
93
  )
80
94
 
81
95
 
@@ -86,7 +100,7 @@ def _like_escape(s: str) -> str:
86
100
 
87
101
 
88
102
  def _row_to_entry(row) -> LogEntry:
89
- row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json = row
103
+ row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json, user_id = row
90
104
  return LogEntry(
91
105
  direction=direction,
92
106
  admin=bool(admin),
@@ -97,6 +111,7 @@ def _row_to_entry(row) -> LogEntry:
97
111
  msg_type_name=mt_name,
98
112
  fields=json.loads(fields_json),
99
113
  id=row_id,
114
+ user_id=user_id,
100
115
  )
101
116
 
102
117
 
@@ -294,11 +309,12 @@ class MessageStore:
294
309
  # Write
295
310
  # ------------------------------------------------------------------
296
311
 
297
- def add(self, direction: str, admin: bool, raw: str) -> LogEntry:
312
+ def add(self, direction: str, admin: bool, raw: str, user_id: Optional[str] = None) -> LogEntry:
298
313
  """Parse message, enqueue DB write, return LogEntry immediately."""
299
- return self.add_entry(direction, admin, raw, parse_raw(raw))
314
+ return self.add_entry(direction, admin, raw, parse_raw(raw), user_id=user_id)
300
315
 
301
- def add_entry(self, direction: str, admin: bool, raw: str, fields: dict) -> LogEntry:
316
+ def add_entry(self, direction: str, admin: bool, raw: str, fields: dict,
317
+ user_id: Optional[str] = None) -> LogEntry:
302
318
  """Build + enqueue a LogEntry from pre-parsed fields (no re-parse)."""
303
319
  mt = get_msg_type(fields)
304
320
  entry = LogEntry(
@@ -310,6 +326,7 @@ class MessageStore:
310
326
  msg_type=mt,
311
327
  msg_type_name=msg_type_name(mt) if mt else "Unknown",
312
328
  fields=fields,
329
+ user_id=user_id,
313
330
  )
314
331
  self._writer.enqueue(self._session_id, entry)
315
332
  return entry
@@ -354,10 +371,7 @@ class MessageStore:
354
371
  params.append(before_id)
355
372
 
356
373
  where_sql = " AND ".join(where)
357
- select = (
358
- "SELECT id, session_id, ts, direction, admin, seq_num, "
359
- f"msg_type, msg_type_name, raw, fields_json FROM messages WHERE {where_sql}"
360
- )
374
+ select = f"SELECT {_COLUMNS} FROM messages WHERE {where_sql}"
361
375
 
362
376
  if limit is not None:
363
377
  # Fetch last N qualifying rows in ascending order
@@ -392,11 +406,7 @@ class MessageStore:
392
406
  return []
393
407
 
394
408
  hay = "replace(raw, char(1), '|')"
395
- select = (
396
- "SELECT id, session_id, ts, direction, admin, seq_num, "
397
- "msg_type, msg_type_name, raw, fields_json FROM messages "
398
- "WHERE session_id = ?"
399
- )
409
+ select = f"SELECT {_COLUMNS} FROM messages WHERE session_id = ?"
400
410
  params: list = [self._session_id]
401
411
 
402
412
  with closing(_connect(self._db_path)) as conn:
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import contextvars
2
3
  import logging
3
4
  import os
4
5
  import time
@@ -18,6 +19,16 @@ from .fix_application import FixApplication
18
19
  if TYPE_CHECKING:
19
20
  from .message_store import MessageWriter
20
21
 
22
+ # The uid of the user driving the current send, or None for engine/auto sends.
23
+ # Set in `send_message` before `await send_app`; read when fixcore's to_app/
24
+ # to_admin callback logs the outbound message. A ContextVar (not an instance
25
+ # attr) because fixcore invokes those callbacks synchronously within the same
26
+ # asyncio task under its _send_lock — task-local storage keeps concurrent
27
+ # senders on one session from cross-attributing.
28
+ _sending_user: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
29
+ "fixture_sending_user", default=None,
30
+ )
31
+
21
32
  logger = logging.getLogger(__name__)
22
33
 
23
34
 
@@ -191,18 +202,26 @@ class Session:
191
202
  },
192
203
  ))
193
204
 
194
- async def send_message(self, message: Message) -> bool:
195
- """Send an application-level FIX message. Returns False if not logged on."""
205
+ async def send_message(self, message: Message, user_id: Optional[str] = None) -> bool:
206
+ """Send an application-level FIX message. Returns False if not logged on.
207
+
208
+ `user_id` attributes the send to a human user; it's stashed in a
209
+ ContextVar that the to_app/to_admin log path reads. None (engine/auto
210
+ sends) logs an unattributed message.
211
+ """
196
212
  if self._transport is None or self._fixcore_session_id is None:
197
213
  return False
198
214
  transport_session = self._transport.sessions.get(self._fixcore_session_id)
199
215
  if transport_session is None or not transport_session.is_logged_on:
200
216
  return False
217
+ token = _sending_user.set(user_id)
201
218
  try:
202
219
  await transport_session.send_app(message)
203
220
  return True
204
221
  except Exception:
205
222
  return False
223
+ finally:
224
+ _sending_user.reset(token)
206
225
 
207
226
  # ------------------------------------------------------------------
208
227
  # Sequence number management
@@ -271,15 +290,19 @@ class Session:
271
290
  admin = event.payload.get("admin", False)
272
291
  raw = event.payload.get("raw", "")
273
292
  fields = event.payload.get("fields")
293
+ # Attribute outbound sends to the user driving them (set in
294
+ # send_message); inbound and engine/auto sends stay unattributed.
295
+ user_id = _sending_user.get() if direction == "OUT" else None
274
296
  if fields is not None:
275
297
  # Pre-parsed by FixApplication from the fixcore Message — no re-parse.
276
- entry = self._message_log.add_entry(direction, admin, raw, fields)
298
+ entry = self._message_log.add_entry(direction, admin, raw, fields, user_id=user_id)
277
299
  else:
278
- entry = self._message_log.add(direction, admin, raw)
300
+ entry = self._message_log.add(direction, admin, raw, user_id=user_id)
279
301
  event.payload["seq_num"] = entry.seq_num
280
302
  event.payload["msg_type"] = entry.msg_type
281
303
  event.payload["msg_type_name"] = entry.msg_type_name
282
304
  event.payload["fields"] = entry.fields
305
+ event.payload["user_id"] = entry.user_id
283
306
  if direction == "OUT":
284
307
  self.state.last_sent_seq_num = entry.seq_num
285
308
  else:
@@ -170,8 +170,8 @@ class SessionManager:
170
170
  def set_seqnums(self, session_id: str, sender, target) -> tuple:
171
171
  return self._get(session_id).set_seqnums(sender, target)
172
172
 
173
- async def send_message(self, session_id: str, message) -> bool:
174
- return await self._get(session_id).send_message(message)
173
+ async def send_message(self, session_id: str, message, user_id: Optional[str] = None) -> bool:
174
+ return await self._get(session_id).send_message(message, user_id=user_id)
175
175
 
176
176
  # ------------------------------------------------------------------
177
177
  # Event subscription