fixtureqa 0.3.5__tar.gz → 0.4.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.3.5/fixtureqa.egg-info → fixtureqa-0.4.0}/PKG-INFO +1 -1
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/app.py +1 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/connection_manager.py +68 -50
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/deps.py +17 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/messages.py +6 -17
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/scenarios.py +16 -2
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/sessions.py +49 -103
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/ws.py +6 -4
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/config_store.py +19 -28
- fixtureqa-0.4.0/fixture/core/fix_time.py +18 -0
- fixtureqa-0.4.0/fixture/core/json_store.py +82 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/message_store.py +40 -2
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_engine.py +2 -2
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_payload.py +2 -2
- fixtureqa-0.4.0/fixture/core/perf_store.py +35 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_writer.py +40 -12
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/scenario_runner.py +133 -85
- fixtureqa-0.4.0/fixture/core/scenario_store.py +36 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/session.py +37 -7
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/session_manager.py +38 -6
- fixtureqa-0.4.0/fixture/core/template_store.py +36 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/venue_responses.py +3 -2
- fixtureqa-0.4.0/fixture/static/assets/index-CpzFFtxH.js +102 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/index.html +1 -1
- {fixtureqa-0.3.5 → fixtureqa-0.4.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/SOURCES.txt +4 -1
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/pyproject.toml +1 -1
- fixtureqa-0.4.0/tests/test_config_store.py +78 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_connection_manager.py +5 -2
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_message_store.py +69 -0
- fixtureqa-0.4.0/tests/test_scenarios.py +197 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_session_lifecycle.py +8 -4
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_sessions.py +29 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_templates.py +15 -0
- fixtureqa-0.3.5/fixture/core/perf_store.py +0 -46
- fixtureqa-0.3.5/fixture/core/scenario_store.py +0 -66
- fixtureqa-0.3.5/fixture/core/template_store.py +0 -66
- fixtureqa-0.3.5/fixture/static/assets/index-BYDmHEr1.js +0 -102
- fixtureqa-0.3.5/tests/test_scenarios.py +0 -75
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/LICENSE +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/README.md +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/server.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/index-D9vW5wFo.css +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/setup.cfg +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_health.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_ws.py +0 -0
|
@@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
|
|
|
30
30
|
loop = asyncio.get_running_loop()
|
|
31
31
|
app.state.loop = loop
|
|
32
32
|
app.state.scenario_runner.set_loop(loop)
|
|
33
|
+
app.state.connection_manager.set_loop(loop)
|
|
33
34
|
# Register event bridge with SessionManager
|
|
34
35
|
sm: SessionManager = app.state.session_manager
|
|
35
36
|
cm: ConnectionManager = app.state.connection_manager
|
|
@@ -5,6 +5,7 @@ fixcore callbacks fire on the asyncio event loop — no thread-crossing needed.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
|
+
import json
|
|
8
9
|
import logging
|
|
9
10
|
from datetime import datetime, timezone
|
|
10
11
|
|
|
@@ -79,6 +80,9 @@ class ConnectionManager:
|
|
|
79
80
|
|
|
80
81
|
def __init__(self):
|
|
81
82
|
self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
|
|
83
|
+
# The FastAPI/uvicorn event loop, bound in the app lifespan — needed to
|
|
84
|
+
# schedule broadcasts submitted from worker threads (scenario runner).
|
|
85
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
82
86
|
# Hold strong references to in-flight broadcast tasks. asyncio only keeps
|
|
83
87
|
# a weak reference to tasks, so a fire-and-forget create_task() can be
|
|
84
88
|
# garbage-collected mid-execution, silently dropping a broadcast.
|
|
@@ -185,63 +189,77 @@ class ConnectionManager:
|
|
|
185
189
|
}
|
|
186
190
|
await self._broadcast(sid, payload, c["owner"])
|
|
187
191
|
|
|
192
|
+
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
193
|
+
"""Bind the event loop that off-loop callers submit broadcasts to."""
|
|
194
|
+
self._loop = loop
|
|
195
|
+
|
|
188
196
|
def broadcast_scenario_event(self, uid: str, data: dict) -> None:
|
|
189
|
-
"""Schedule delivery to all WS connections for uid.
|
|
190
|
-
|
|
197
|
+
"""Schedule delivery to all WS connections for uid.
|
|
198
|
+
|
|
199
|
+
Thread-safe: the scenario runner calls this from its worker threads,
|
|
200
|
+
where create_task raises RuntimeError (no running loop in that thread)
|
|
201
|
+
— which killed the run thread on its very first scenario_start
|
|
202
|
+
broadcast after the fixcore migration dropped call_soon_threadsafe.
|
|
203
|
+
"""
|
|
204
|
+
coro = self._broadcast_to_user(uid, data)
|
|
205
|
+
try:
|
|
206
|
+
asyncio.get_running_loop()
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
loop = self._loop
|
|
209
|
+
if loop is None or loop.is_closed():
|
|
210
|
+
coro.close()
|
|
211
|
+
return
|
|
212
|
+
try:
|
|
213
|
+
asyncio.run_coroutine_threadsafe(coro, loop)
|
|
214
|
+
except RuntimeError:
|
|
215
|
+
# Loop closed between the check and the submit (app shutting
|
|
216
|
+
# down while a scenario run is mid-flight) — drop the broadcast
|
|
217
|
+
# rather than killing the run thread.
|
|
218
|
+
coro.close()
|
|
219
|
+
return
|
|
220
|
+
self._spawn(coro)
|
|
191
221
|
|
|
192
222
|
# ------------------------------------------------------------------
|
|
193
223
|
# Internal async broadcast
|
|
194
224
|
# ------------------------------------------------------------------
|
|
195
225
|
|
|
196
|
-
async def
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
except Exception:
|
|
220
|
-
for key, sockets in self._clients.items():
|
|
221
|
-
if ws in sockets:
|
|
222
|
-
dead.append((ws, key))
|
|
226
|
+
async def _send_text(self, ws: WebSocket, text: str) -> bool:
|
|
227
|
+
"""Send pre-serialized JSON to one socket. False = socket is dead."""
|
|
228
|
+
try:
|
|
229
|
+
if ws.client_state == WebSocketState.CONNECTED:
|
|
230
|
+
await ws.send_text(text)
|
|
231
|
+
return True
|
|
232
|
+
except Exception:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def _prune(self, dead: list[WebSocket]) -> None:
|
|
236
|
+
for ws in dead:
|
|
237
|
+
for sockets in self._clients.values():
|
|
238
|
+
sockets.pop(ws, None)
|
|
239
|
+
|
|
240
|
+
async def _fan_out(self, targets: list[WebSocket], payload: dict) -> None:
|
|
241
|
+
"""Serialize once and send concurrently — per-socket send_json would
|
|
242
|
+
re-encode the same payload per client, and sequential awaits let one
|
|
243
|
+
slow client head-of-line-block delivery to everyone else."""
|
|
244
|
+
if not targets:
|
|
245
|
+
return
|
|
246
|
+
text = json.dumps(payload)
|
|
247
|
+
oks = await asyncio.gather(*(self._send_text(ws, text) for ws in targets))
|
|
248
|
+
self._prune([ws for ws, ok in zip(targets, oks) if not ok])
|
|
223
249
|
|
|
224
|
-
|
|
225
|
-
|
|
250
|
+
async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
|
|
251
|
+
# A connection receives the event if it is an admin, or its uid
|
|
252
|
+
# matches the session owner.
|
|
253
|
+
targets = [ws for ws, info in {**self._clients.get(session_id, {}),
|
|
254
|
+
**self._clients.get("*", {})}.items()
|
|
255
|
+
if info.is_admin or info.uid == owner_uid]
|
|
256
|
+
await self._fan_out(targets, payload)
|
|
226
257
|
|
|
227
258
|
async def _broadcast_to_user(self, uid: str, data: dict) -> None:
|
|
228
|
-
"""Send data to all WS connections belonging to uid."""
|
|
229
|
-
all_clients = {}
|
|
259
|
+
"""Send data to all WS connections belonging to uid (admins included)."""
|
|
260
|
+
all_clients: dict[WebSocket, _ConnInfo] = {}
|
|
230
261
|
for sockets in self._clients.values():
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
for ws, info in all_clients.items():
|
|
236
|
-
if info.uid != uid and not info.is_admin:
|
|
237
|
-
continue
|
|
238
|
-
try:
|
|
239
|
-
if ws.client_state == WebSocketState.CONNECTED:
|
|
240
|
-
await ws.send_json(data)
|
|
241
|
-
except Exception:
|
|
242
|
-
for key, sockets in self._clients.items():
|
|
243
|
-
if ws in sockets:
|
|
244
|
-
dead.append((ws, key))
|
|
245
|
-
|
|
246
|
-
for ws, key in dead:
|
|
247
|
-
self._clients.get(key, {}).pop(ws, None)
|
|
262
|
+
all_clients.update(sockets)
|
|
263
|
+
targets = [ws for ws, info in all_clients.items()
|
|
264
|
+
if info.uid == uid or info.is_admin]
|
|
265
|
+
await self._fan_out(targets, data)
|
|
@@ -59,6 +59,23 @@ def require_platform_admin(user: User = Depends(get_current_user)) -> User:
|
|
|
59
59
|
return user
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def get_owned_session(
|
|
63
|
+
session_id: str,
|
|
64
|
+
request: HTTPConnection,
|
|
65
|
+
user: User = Depends(get_current_user),
|
|
66
|
+
):
|
|
67
|
+
"""Resolve the `session_id` path param to its SessionConfig, enforcing
|
|
68
|
+
that the caller owns it (or is admin). One dependency instead of a
|
|
69
|
+
_check_ownership copy in every session-scoped router."""
|
|
70
|
+
sm: SessionManager = request.app.state.session_manager
|
|
71
|
+
cfg = sm.get_config(session_id)
|
|
72
|
+
if cfg is None:
|
|
73
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
74
|
+
if not user.is_admin and cfg.owner_uid != user.uid:
|
|
75
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
76
|
+
return cfg
|
|
77
|
+
|
|
78
|
+
|
|
62
79
|
def get_housekeeping(request: HTTPConnection) -> HousekeepingService:
|
|
63
80
|
return request.app.state.housekeeping
|
|
64
81
|
|
|
@@ -4,38 +4,29 @@ from typing import Annotated, Optional
|
|
|
4
4
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
5
5
|
from fastapi.responses import StreamingResponse
|
|
6
6
|
|
|
7
|
+
from ...core.models import SessionConfig
|
|
7
8
|
from ...core.session_manager import SessionManager
|
|
8
9
|
from ...core.user_store import User
|
|
9
10
|
from ..schemas import MessageEntryResponse
|
|
10
|
-
from ..deps import get_session_manager,
|
|
11
|
+
from ..deps import get_session_manager, get_owned_session
|
|
11
12
|
|
|
12
13
|
router = APIRouter(tags=["messages"])
|
|
13
14
|
|
|
14
15
|
SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _check_ownership(sm: SessionManager, session_id: str, user: User) -> None:
|
|
19
|
-
configs = sm.list_sessions()
|
|
20
|
-
cfg = next((c for c in configs if c.session_id == session_id), None)
|
|
21
|
-
if cfg is None:
|
|
22
|
-
raise HTTPException(status_code=404, detail="Session not found")
|
|
23
|
-
if not user.is_admin and cfg.owner_uid != user.uid:
|
|
24
|
-
raise HTTPException(status_code=403, detail="Access denied")
|
|
16
|
+
OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
|
|
25
17
|
|
|
26
18
|
|
|
27
19
|
@router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
|
|
28
20
|
def get_messages(
|
|
29
21
|
session_id: str,
|
|
22
|
+
_cfg: OwnedSession,
|
|
30
23
|
sm: SM,
|
|
31
|
-
user: CurrentUser,
|
|
32
24
|
direction: Optional[str] = Query(default=None, description="IN or OUT"),
|
|
33
25
|
admin: Optional[bool] = Query(default=None),
|
|
34
26
|
msg_type: Optional[str] = Query(default=None),
|
|
35
27
|
limit: int = Query(default=200, ge=1, le=5000),
|
|
36
28
|
before_id: Optional[int] = Query(default=None, description="Cursor: return rows with id < before_id"),
|
|
37
29
|
):
|
|
38
|
-
_check_ownership(sm, session_id, user)
|
|
39
30
|
try:
|
|
40
31
|
state = sm.get_state(session_id)
|
|
41
32
|
except KeyError:
|
|
@@ -72,11 +63,10 @@ def get_messages(
|
|
|
72
63
|
@router.get("/{session_id}/messages/export")
|
|
73
64
|
def export_messages(
|
|
74
65
|
session_id: str,
|
|
66
|
+
_cfg: OwnedSession,
|
|
75
67
|
sm: SM,
|
|
76
|
-
user: CurrentUser,
|
|
77
68
|
fmt: str = Query(default="csv", description="Export format: csv or fix"),
|
|
78
69
|
):
|
|
79
|
-
_check_ownership(sm, session_id, user)
|
|
80
70
|
try:
|
|
81
71
|
state = sm.get_state(session_id)
|
|
82
72
|
except KeyError:
|
|
@@ -127,8 +117,7 @@ def export_messages(
|
|
|
127
117
|
|
|
128
118
|
|
|
129
119
|
@router.delete("/{session_id}/messages", status_code=204)
|
|
130
|
-
def clear_messages(session_id: str,
|
|
131
|
-
_check_ownership(sm, session_id, user)
|
|
120
|
+
def clear_messages(session_id: str, _cfg: OwnedSession, sm: SM):
|
|
132
121
|
try:
|
|
133
122
|
state = sm.get_state(session_id)
|
|
134
123
|
except KeyError:
|
|
@@ -3,14 +3,17 @@ from typing import Annotated
|
|
|
3
3
|
|
|
4
4
|
from ...core.scenario_store import ScenarioStore
|
|
5
5
|
from ...core.scenario_runner import ScenarioRunner
|
|
6
|
+
from ...core.session_manager import SessionManager
|
|
6
7
|
from ...core.user_store import User
|
|
7
|
-
from ..deps import get_current_user, get_scenario_store, get_scenario_runner
|
|
8
|
+
from ..deps import (get_current_user, get_scenario_store, get_scenario_runner,
|
|
9
|
+
get_session_manager)
|
|
8
10
|
from ..schemas import ScenarioRequest, ScenarioResponse
|
|
9
11
|
|
|
10
12
|
router = APIRouter(tags=["scenarios"])
|
|
11
13
|
|
|
12
14
|
SS = Depends(get_scenario_store)
|
|
13
15
|
SR = Depends(get_scenario_runner)
|
|
16
|
+
SM = Depends(get_session_manager)
|
|
14
17
|
CurrentUser = Depends(get_current_user)
|
|
15
18
|
|
|
16
19
|
|
|
@@ -50,16 +53,27 @@ def run_scenario(
|
|
|
50
53
|
scenario_id: str,
|
|
51
54
|
store: ScenarioStore = SS,
|
|
52
55
|
runner: ScenarioRunner = SR,
|
|
56
|
+
sm: SessionManager = SM,
|
|
53
57
|
user: User = CurrentUser,
|
|
54
58
|
):
|
|
55
59
|
scenario = store.get_scenario(user.uid, scenario_id)
|
|
56
60
|
if scenario is None:
|
|
57
61
|
raise HTTPException(status_code=404, detail="Scenario not found")
|
|
62
|
+
# The session_id is scenario data, not a path param — enforce ownership
|
|
63
|
+
# here or any user could drive another user's session through a scenario.
|
|
64
|
+
cfg = sm.get_config(scenario.get("session_id", ""))
|
|
65
|
+
if cfg is None:
|
|
66
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
67
|
+
if not user.is_admin and cfg.owner_uid != user.uid:
|
|
68
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
58
69
|
run_id = runner.run(scenario, user.uid)
|
|
59
70
|
return {"run_id": run_id}
|
|
60
71
|
|
|
61
72
|
|
|
62
73
|
@router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
63
74
|
def abort_run(run_id: str, runner: ScenarioRunner = SR, user: User = CurrentUser):
|
|
64
|
-
|
|
75
|
+
try:
|
|
76
|
+
runner.abort(run_id, uid=user.uid, is_admin=user.is_admin)
|
|
77
|
+
except PermissionError:
|
|
78
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
65
79
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
from typing import Annotated, Optional
|
|
2
2
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
3
3
|
|
|
4
|
+
from ...core.message_store import MessageStore
|
|
4
5
|
from ...core.session_manager import SessionManager
|
|
5
6
|
from ...core.models import SessionConfig, ConnectionType, SessionRole
|
|
6
7
|
from ...core.user_store import User, UserStore
|
|
7
8
|
from ..schemas import SessionConfigRequest, SessionResponse, UpdateSessionRequest, SendMessageRequest, SeqNumRequest, SeqNumResponse
|
|
8
|
-
from ..deps import get_session_manager, get_current_user, get_user_store
|
|
9
|
+
from ..deps import get_session_manager, get_current_user, get_owned_session, get_user_store
|
|
9
10
|
|
|
10
11
|
router = APIRouter(tags=["sessions"])
|
|
11
12
|
|
|
12
13
|
SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
13
14
|
US = Annotated[UserStore, Depends(get_user_store)]
|
|
14
15
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
16
|
+
OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
def _config_from_request(body: SessionConfigRequest, owner_uid: str) -> SessionConfig:
|
|
20
|
+
"""Map the request schema onto SessionConfig in one place — create and
|
|
21
|
+
import used to hand-copy 24 fields each, and a field missed in one copy
|
|
22
|
+
silently lost its value on that path."""
|
|
23
|
+
try:
|
|
24
|
+
ct = ConnectionType(body.connection_type)
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
|
|
27
|
+
try:
|
|
28
|
+
role = SessionRole(body.session_role)
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
|
|
31
|
+
data = body.model_dump(exclude={"connection_type", "session_role", "auto_start"})
|
|
32
|
+
return SessionConfig(**data, connection_type=ct, session_role=role, owner_uid=owner_uid)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStore] = None,
|
|
36
|
+
counts: Optional[dict] = None) -> SessionResponse:
|
|
37
|
+
cfg = sm.get_config(session_id)
|
|
38
|
+
if cfg is None:
|
|
20
39
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
21
|
-
cfg = cfg_list[0]
|
|
22
40
|
state = sm.get_state(session_id)
|
|
23
41
|
log = state.message_log
|
|
24
42
|
owner_username: Optional[str] = None
|
|
25
43
|
if us is not None:
|
|
26
44
|
owner = us.get_by_uid(cfg.owner_uid)
|
|
27
45
|
owner_username = owner.username if owner else None
|
|
46
|
+
if counts is not None and isinstance(log, MessageStore):
|
|
47
|
+
# List endpoint: one GROUP BY for all sessions instead of COUNT(*) each.
|
|
48
|
+
message_count = counts.get(session_id, 0)
|
|
49
|
+
else:
|
|
50
|
+
message_count = len(log) if log else 0
|
|
28
51
|
return SessionResponse(
|
|
29
52
|
session_id=cfg.session_id,
|
|
30
53
|
display_name=cfg.display_name,
|
|
@@ -53,24 +76,13 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
|
|
|
53
76
|
status=state.status.name,
|
|
54
77
|
last_sent_seq_num=state.last_sent_seq_num,
|
|
55
78
|
last_recv_seq_num=state.last_recv_seq_num,
|
|
56
|
-
message_count=
|
|
79
|
+
message_count=message_count,
|
|
57
80
|
status_changed_at=state.status_changed_at,
|
|
58
81
|
last_heartbeat_at=state.last_heartbeat_at,
|
|
59
82
|
owner_username=owner_username,
|
|
60
83
|
)
|
|
61
84
|
|
|
62
85
|
|
|
63
|
-
def _check_ownership(sm: SessionManager, session_id: str, user: User) -> SessionConfig:
|
|
64
|
-
"""Return the SessionConfig if the user owns it (or is admin). Raises 404/403 otherwise."""
|
|
65
|
-
configs = sm.list_sessions()
|
|
66
|
-
cfg = next((c for c in configs if c.session_id == session_id), None)
|
|
67
|
-
if cfg is None:
|
|
68
|
-
raise HTTPException(status_code=404, detail="Session not found")
|
|
69
|
-
if not user.is_admin and cfg.owner_uid != user.uid:
|
|
70
|
-
raise HTTPException(status_code=403, detail="Access denied")
|
|
71
|
-
return cfg
|
|
72
|
-
|
|
73
|
-
|
|
74
86
|
def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
|
|
75
87
|
"""Only admin responses carry owner_username (the UI owner badge / Mine filter)."""
|
|
76
88
|
return us if user.is_admin else None
|
|
@@ -80,46 +92,13 @@ def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
|
|
|
80
92
|
def list_sessions(sm: SM, us: US, user: CurrentUser):
|
|
81
93
|
configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
|
|
82
94
|
user_store = _owner_store(us, user)
|
|
83
|
-
|
|
95
|
+
counts = sm.message_counts()
|
|
96
|
+
return [_session_response(sm, cfg.session_id, user_store, counts) for cfg in configs]
|
|
84
97
|
|
|
85
98
|
|
|
86
99
|
@router.post("/", response_model=SessionResponse, status_code=201)
|
|
87
100
|
async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: CurrentUser):
|
|
88
|
-
|
|
89
|
-
ct = ConnectionType(body.connection_type)
|
|
90
|
-
except ValueError:
|
|
91
|
-
raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
|
|
92
|
-
try:
|
|
93
|
-
role = SessionRole(body.session_role)
|
|
94
|
-
except ValueError:
|
|
95
|
-
raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
|
|
96
|
-
|
|
97
|
-
cfg = SessionConfig(
|
|
98
|
-
display_name=body.display_name,
|
|
99
|
-
begin_string=body.begin_string,
|
|
100
|
-
sender_comp_id=body.sender_comp_id,
|
|
101
|
-
target_comp_id=body.target_comp_id,
|
|
102
|
-
connection_type=ct,
|
|
103
|
-
host=body.host,
|
|
104
|
-
port=body.port,
|
|
105
|
-
heartbeat_interval=body.heartbeat_interval,
|
|
106
|
-
reconnect_interval=body.reconnect_interval,
|
|
107
|
-
data_dictionary_path=body.data_dictionary_path,
|
|
108
|
-
use_file_log=body.use_file_log,
|
|
109
|
-
start_time=body.start_time,
|
|
110
|
-
end_time=body.end_time,
|
|
111
|
-
reset_on_logon=body.reset_on_logon,
|
|
112
|
-
reset_on_logout=body.reset_on_logout,
|
|
113
|
-
durable_seqnums=body.durable_seqnums,
|
|
114
|
-
log_messages=body.log_messages,
|
|
115
|
-
validate_inbound=body.validate_inbound,
|
|
116
|
-
session_role=role,
|
|
117
|
-
auto_ack=body.auto_ack,
|
|
118
|
-
auto_ack_delay_ms=body.auto_ack_delay_ms,
|
|
119
|
-
auto_fill=body.auto_fill,
|
|
120
|
-
auto_fill_delay_ms=body.auto_fill_delay_ms,
|
|
121
|
-
owner_uid=user.uid,
|
|
122
|
-
)
|
|
101
|
+
cfg = _config_from_request(body, owner_uid=user.uid)
|
|
123
102
|
|
|
124
103
|
try:
|
|
125
104
|
session_id = sm.add_session(cfg)
|
|
@@ -139,56 +118,28 @@ async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: Curre
|
|
|
139
118
|
def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, user: CurrentUser):
|
|
140
119
|
"""
|
|
141
120
|
Bulk-create sessions from an exported config file.
|
|
142
|
-
Each entry is processed independently; invalid entries
|
|
143
|
-
Returns the
|
|
121
|
+
Each entry is processed independently; invalid entries (bad enums, port
|
|
122
|
+
conflicts, duplicate ids) are skipped. Returns the successfully created
|
|
123
|
+
sessions.
|
|
144
124
|
"""
|
|
145
125
|
created = []
|
|
146
126
|
for item in body:
|
|
147
127
|
try:
|
|
148
|
-
|
|
149
|
-
role = SessionRole(item.session_role)
|
|
150
|
-
cfg = SessionConfig(
|
|
151
|
-
display_name=item.display_name,
|
|
152
|
-
begin_string=item.begin_string,
|
|
153
|
-
sender_comp_id=item.sender_comp_id,
|
|
154
|
-
target_comp_id=item.target_comp_id,
|
|
155
|
-
connection_type=ct,
|
|
156
|
-
host=item.host,
|
|
157
|
-
port=item.port,
|
|
158
|
-
heartbeat_interval=item.heartbeat_interval,
|
|
159
|
-
reconnect_interval=item.reconnect_interval,
|
|
160
|
-
data_dictionary_path=item.data_dictionary_path,
|
|
161
|
-
use_file_log=item.use_file_log,
|
|
162
|
-
start_time=item.start_time,
|
|
163
|
-
end_time=item.end_time,
|
|
164
|
-
reset_on_logon=item.reset_on_logon,
|
|
165
|
-
reset_on_logout=item.reset_on_logout,
|
|
166
|
-
durable_seqnums=item.durable_seqnums,
|
|
167
|
-
log_messages=item.log_messages,
|
|
168
|
-
validate_inbound=item.validate_inbound,
|
|
169
|
-
session_role=role,
|
|
170
|
-
auto_ack=item.auto_ack,
|
|
171
|
-
auto_ack_delay_ms=item.auto_ack_delay_ms,
|
|
172
|
-
auto_fill=item.auto_fill,
|
|
173
|
-
auto_fill_delay_ms=item.auto_fill_delay_ms,
|
|
174
|
-
owner_uid=user.uid,
|
|
175
|
-
)
|
|
128
|
+
cfg = _config_from_request(item, owner_uid=user.uid)
|
|
176
129
|
session_id = sm.add_session(cfg)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
130
|
+
except (HTTPException, ValueError, KeyError):
|
|
131
|
+
continue # invalid / conflicting entry — skip just this one
|
|
132
|
+
created.append(_session_response(sm, session_id, _owner_store(us, user)))
|
|
180
133
|
return created
|
|
181
134
|
|
|
182
135
|
|
|
183
136
|
@router.get("/{session_id}", response_model=SessionResponse)
|
|
184
|
-
def get_session(session_id: str, sm: SM, us: US, user: CurrentUser):
|
|
185
|
-
_check_ownership(sm, session_id, user)
|
|
137
|
+
def get_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
|
|
186
138
|
return _session_response(sm, session_id, _owner_store(us, user))
|
|
187
139
|
|
|
188
140
|
|
|
189
141
|
@router.patch("/{session_id}", response_model=SessionResponse)
|
|
190
|
-
def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, us: US, user: CurrentUser):
|
|
191
|
-
_check_ownership(sm, session_id, user)
|
|
142
|
+
def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
|
|
192
143
|
try:
|
|
193
144
|
sm.update_session(session_id, body)
|
|
194
145
|
except RuntimeError as e:
|
|
@@ -199,8 +150,7 @@ def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, us: US,
|
|
|
199
150
|
|
|
200
151
|
|
|
201
152
|
@router.delete("/{session_id}", status_code=204)
|
|
202
|
-
async def remove_session(session_id: str,
|
|
203
|
-
_check_ownership(sm, session_id, user)
|
|
153
|
+
async def remove_session(session_id: str, _cfg: OwnedSession, sm: SM):
|
|
204
154
|
try:
|
|
205
155
|
await sm.remove_session(session_id)
|
|
206
156
|
except KeyError:
|
|
@@ -208,8 +158,7 @@ async def remove_session(session_id: str, sm: SM, user: CurrentUser):
|
|
|
208
158
|
|
|
209
159
|
|
|
210
160
|
@router.post("/{session_id}/start", response_model=SessionResponse)
|
|
211
|
-
async def start_session(session_id: str, sm: SM, us: US, user: CurrentUser):
|
|
212
|
-
_check_ownership(sm, session_id, user)
|
|
161
|
+
async def start_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
|
|
213
162
|
try:
|
|
214
163
|
await sm.start_session(session_id)
|
|
215
164
|
except RuntimeError as e:
|
|
@@ -220,25 +169,24 @@ async def start_session(session_id: str, sm: SM, us: US, user: CurrentUser):
|
|
|
220
169
|
@router.post("/{session_id}/stop", response_model=SessionResponse)
|
|
221
170
|
async def stop_session(
|
|
222
171
|
session_id: str,
|
|
172
|
+
_cfg: OwnedSession,
|
|
223
173
|
sm: SM,
|
|
224
174
|
us: US,
|
|
225
175
|
user: CurrentUser,
|
|
226
176
|
force: bool = Query(default=False),
|
|
227
177
|
):
|
|
228
|
-
_check_ownership(sm, session_id, user)
|
|
229
178
|
await sm.stop_session(session_id, force=force)
|
|
230
179
|
return _session_response(sm, session_id, _owner_store(us, user))
|
|
231
180
|
|
|
232
181
|
|
|
233
182
|
@router.post("/{session_id}/send")
|
|
234
|
-
async def send_message(session_id: str, body: SendMessageRequest,
|
|
235
|
-
_check_ownership(sm, session_id, user)
|
|
183
|
+
async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSession, sm: SM):
|
|
236
184
|
from ...core.fix_builder import build_message_from_raw
|
|
237
|
-
from
|
|
185
|
+
from ...core.fix_time import utc_timestamp
|
|
238
186
|
|
|
239
187
|
# Build group-aware using the session's DataDictionary so repeating groups
|
|
240
188
|
# (e.g. NoAllocs rows) survive instead of collapsing to their last instance.
|
|
241
|
-
ts = None if body.no_auto_ts else
|
|
189
|
+
ts = None if body.no_auto_ts else utc_timestamp()
|
|
242
190
|
try:
|
|
243
191
|
msg = build_message_from_raw(body.raw, sm.get_data_dictionary(session_id), transact_time=ts)
|
|
244
192
|
except ValueError as e:
|
|
@@ -251,8 +199,7 @@ async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user:
|
|
|
251
199
|
|
|
252
200
|
|
|
253
201
|
@router.get("/{session_id}/seqnums", response_model=SeqNumResponse)
|
|
254
|
-
def get_seqnums(session_id: str,
|
|
255
|
-
_check_ownership(sm, session_id, user)
|
|
202
|
+
def get_seqnums(session_id: str, _cfg: OwnedSession, sm: SM):
|
|
256
203
|
try:
|
|
257
204
|
sender, target = sm.get_seqnums(session_id)
|
|
258
205
|
except KeyError:
|
|
@@ -261,8 +208,7 @@ def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
|
|
|
261
208
|
|
|
262
209
|
|
|
263
210
|
@router.patch("/{session_id}/seqnums", response_model=SeqNumResponse)
|
|
264
|
-
def set_seqnums(session_id: str, body: SeqNumRequest,
|
|
265
|
-
_check_ownership(sm, session_id, user)
|
|
211
|
+
def set_seqnums(session_id: str, body: SeqNumRequest, _cfg: OwnedSession, sm: SM):
|
|
266
212
|
if body.sender is None and body.target is None:
|
|
267
213
|
raise HTTPException(status_code=422, detail="Provide sender, target, or both")
|
|
268
214
|
try:
|
|
@@ -81,7 +81,10 @@ async def ws_all_sessions(
|
|
|
81
81
|
"""Subscribe to events from all sessions."""
|
|
82
82
|
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
83
83
|
if not uid:
|
|
84
|
-
|
|
84
|
+
# Routine, not actionable: pre-login tabs, expired tokens, or a proxy
|
|
85
|
+
# stripping Sec-WebSocket-Protocol all land here and the client retries
|
|
86
|
+
# — at WARNING this used to flood the log with one line per attempt.
|
|
87
|
+
logger.debug("WS /ws: auth failed (no token or invalid JWT)")
|
|
85
88
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
86
89
|
return
|
|
87
90
|
|
|
@@ -122,7 +125,7 @@ async def ws_single_session(
|
|
|
122
125
|
"""Subscribe to events from a single session."""
|
|
123
126
|
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
124
127
|
if not uid:
|
|
125
|
-
logger.
|
|
128
|
+
logger.debug("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
|
|
126
129
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
127
130
|
return
|
|
128
131
|
|
|
@@ -134,8 +137,7 @@ async def ws_single_session(
|
|
|
134
137
|
return
|
|
135
138
|
|
|
136
139
|
# Check ownership of this specific session
|
|
137
|
-
|
|
138
|
-
cfg = next((c for c in configs if c.session_id == session_id), None)
|
|
140
|
+
cfg = sm.get_config(session_id)
|
|
139
141
|
if cfg is None or (not is_admin and cfg.owner_uid != uid):
|
|
140
142
|
logger.warning("WS /ws/%s: access denied for uid=%s", session_id, uid)
|
|
141
143
|
await websocket.close(code=4003, reason="Access denied")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import dataclasses
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
4
|
+
from enum import Enum
|
|
3
5
|
from typing import Dict, List
|
|
4
6
|
|
|
5
7
|
from .atomic_io import atomic_write_json
|
|
@@ -73,32 +75,21 @@ def _load_file(path: str) -> List[SessionConfig]:
|
|
|
73
75
|
return configs
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
# owner_uid is canonical from the directory name, never from file contents.
|
|
79
|
+
_EXCLUDED_FIELDS = {"owner_uid"}
|
|
80
|
+
|
|
81
|
+
|
|
76
82
|
def _config_to_dict(cfg: SessionConfig) -> dict:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"file_log_path": cfg.file_log_path,
|
|
91
|
-
"use_file_log": cfg.use_file_log,
|
|
92
|
-
"start_time": cfg.start_time,
|
|
93
|
-
"end_time": cfg.end_time,
|
|
94
|
-
"reset_on_logon": cfg.reset_on_logon,
|
|
95
|
-
"reset_on_logout": cfg.reset_on_logout,
|
|
96
|
-
"durable_seqnums": cfg.durable_seqnums,
|
|
97
|
-
"log_messages": cfg.log_messages,
|
|
98
|
-
"session_role": cfg.session_role.value,
|
|
99
|
-
"auto_ack": cfg.auto_ack,
|
|
100
|
-
"auto_ack_delay_ms": cfg.auto_ack_delay_ms,
|
|
101
|
-
"auto_fill": cfg.auto_fill,
|
|
102
|
-
"auto_fill_delay_ms": cfg.auto_fill_delay_ms,
|
|
103
|
-
# owner_uid intentionally omitted — canonical from directory name
|
|
104
|
-
}
|
|
83
|
+
"""Serialize every SessionConfig field (enums → values, owner_uid excluded).
|
|
84
|
+
|
|
85
|
+
Derived from dataclasses.fields rather than a hand-maintained key list so a
|
|
86
|
+
newly added field can't be silently dropped from persistence (validate_inbound
|
|
87
|
+
was lost this way and reverted to its default on every restart).
|
|
88
|
+
"""
|
|
89
|
+
out = {}
|
|
90
|
+
for f in dataclasses.fields(SessionConfig):
|
|
91
|
+
if f.name in _EXCLUDED_FIELDS:
|
|
92
|
+
continue
|
|
93
|
+
value = getattr(cfg, f.name)
|
|
94
|
+
out[f.name] = value.value if isinstance(value, Enum) else value
|
|
95
|
+
return out
|