fixtureqa 0.12.1__tar.gz → 0.13.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.
- {fixtureqa-0.12.1/fixtureqa.egg-info → fixtureqa-0.13.0}/PKG-INFO +1 -1
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/app.py +5 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/connection_manager.py +17 -2
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/messages.py +27 -6
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/sessions.py +3 -2
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/schemas.py +2 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/message_log.py +6 -3
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/message_store.py +26 -16
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/session.py +27 -4
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/session_manager.py +2 -2
- fixtureqa-0.12.1/fixture/static/assets/index-DNW24fuM.js → fixtureqa-0.13.0/fixture/static/assets/index--0vO6mor.js +2 -2
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/index.html +1 -1
- {fixtureqa-0.12.1 → fixtureqa-0.13.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixtureqa.egg-info/SOURCES.txt +2 -1
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/pyproject.toml +1 -1
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_db_migrations.py +3 -1
- fixtureqa-0.13.0/tests/test_msg_user_attribution.py +200 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/LICENSE +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/README.md +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/deps.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/specs.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/spec_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/value_gen.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/server.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/assets/index-D0a-mw9U.css +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/setup.cfg +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_fix_spec_tree.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_health.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_non_stop_session.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_specs.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_timezone_data.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_value_gen.py +0 -0
- {fixtureqa-0.12.1 → fixtureqa-0.13.0}/tests/test_ws.py +0 -0
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|