fixtureqa 0.1.8__tar.gz → 0.2.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.1.8/fixtureqa.egg-info → fixtureqa-0.2.0}/PKG-INFO +1 -1
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/app.py +16 -1
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/connection_manager.py +17 -4
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/ws.py +28 -10
- fixtureqa-0.2.0/fixture/core/atomic_io.py +38 -0
- fixtureqa-0.2.0/fixture/core/auth.py +105 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/config_store.py +23 -6
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/custom_tag_store.py +3 -6
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_application.py +13 -2
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/housekeeping.py +11 -2
- fixtureqa-0.2.0/fixture/core/inbound.py +104 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/message_store.py +121 -40
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/scenario_runner.py +13 -2
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/scenario_store.py +3 -7
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/session.py +34 -12
- fixtureqa-0.2.0/fixture/core/session_manager.py +253 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/spec_overlay_store.py +3 -6
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/template_store.py +3 -7
- fixtureqa-0.2.0/fixture/static/assets/index-jaBTfclx.js +102 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/index.html +1 -1
- {fixtureqa-0.1.8 → fixtureqa-0.2.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/SOURCES.txt +11 -2
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/pyproject.toml +1 -1
- fixtureqa-0.2.0/tests/test_atomic_io.py +70 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_auth.py +28 -0
- fixtureqa-0.2.0/tests/test_inbound.py +105 -0
- fixtureqa-0.2.0/tests/test_message_store.py +126 -0
- fixtureqa-0.2.0/tests/test_scenarios.py +75 -0
- fixtureqa-0.2.0/tests/test_session_lifecycle.py +82 -0
- fixtureqa-0.2.0/tests/test_session_manager_concurrency.py +90 -0
- fixtureqa-0.2.0/tests/test_ws.py +54 -0
- fixtureqa-0.1.8/fixture/core/auth.py +0 -68
- fixtureqa-0.1.8/fixture/core/session_manager.py +0 -173
- fixtureqa-0.1.8/fixture/static/assets/index-DnrYtXs9.js +0 -102
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/LICENSE +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/README.md +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/deps.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/server.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/index-Dv0_KeqF.css +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/setup.cfg +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.2.0}/tests/test_templates.py +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import os
|
|
2
3
|
from contextlib import asynccontextmanager
|
|
3
4
|
|
|
@@ -19,6 +20,13 @@ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templ
|
|
|
19
20
|
|
|
20
21
|
@asynccontextmanager
|
|
21
22
|
async def lifespan(app: FastAPI):
|
|
23
|
+
# Capture the running event loop so worker threads (scenarios, and later
|
|
24
|
+
# the perf injector) can submit coroutines back to it via
|
|
25
|
+
# run_coroutine_threadsafe. get_event_loop() does not work from a worker
|
|
26
|
+
# thread on Python 3.12.
|
|
27
|
+
loop = asyncio.get_running_loop()
|
|
28
|
+
app.state.loop = loop
|
|
29
|
+
app.state.scenario_runner.set_loop(loop)
|
|
22
30
|
# Register event bridge with SessionManager
|
|
23
31
|
sm: SessionManager = app.state.session_manager
|
|
24
32
|
cm: ConnectionManager = app.state.connection_manager
|
|
@@ -50,12 +58,19 @@ def create_app(
|
|
|
50
58
|
lifespan=lifespan,
|
|
51
59
|
)
|
|
52
60
|
|
|
61
|
+
# Resolve a stable JWT signing key (persists under data_dir if no env var)
|
|
62
|
+
from ..core.auth import init_secret_key
|
|
63
|
+
init_secret_key(data_dir)
|
|
64
|
+
|
|
53
65
|
# Store singletons on app state (injected via Depends)
|
|
54
66
|
app.state.session_manager = session_manager
|
|
55
67
|
app.state.connection_manager = ConnectionManager()
|
|
56
68
|
user_store = UserStore(db_path)
|
|
57
69
|
app.state.user_store = user_store
|
|
58
|
-
app.state.housekeeping = HousekeepingService(
|
|
70
|
+
app.state.housekeeping = HousekeepingService(
|
|
71
|
+
msg_db_path, log_dir, user_store,
|
|
72
|
+
message_writer=session_manager.message_writer,
|
|
73
|
+
)
|
|
59
74
|
app.state.template_store = TemplateStore(data_dir)
|
|
60
75
|
app.state.scenario_store = ScenarioStore(data_dir)
|
|
61
76
|
app.state.custom_tag_store = CustomTagStore(data_dir)
|
|
@@ -73,14 +73,27 @@ class ConnectionManager:
|
|
|
73
73
|
|
|
74
74
|
def __init__(self):
|
|
75
75
|
self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
|
|
76
|
+
# Hold strong references to in-flight broadcast tasks. asyncio only keeps
|
|
77
|
+
# a weak reference to tasks, so a fire-and-forget create_task() can be
|
|
78
|
+
# garbage-collected mid-execution, silently dropping a broadcast.
|
|
79
|
+
self._tasks: set[asyncio.Task] = set()
|
|
80
|
+
|
|
81
|
+
def _spawn(self, coro) -> None:
|
|
82
|
+
"""Schedule a broadcast coroutine, retaining a reference until it finishes."""
|
|
83
|
+
task = asyncio.create_task(coro)
|
|
84
|
+
self._tasks.add(task)
|
|
85
|
+
task.add_done_callback(self._tasks.discard)
|
|
76
86
|
|
|
77
87
|
# ------------------------------------------------------------------
|
|
78
88
|
# Called from async context (WS handler)
|
|
79
89
|
# ------------------------------------------------------------------
|
|
80
90
|
|
|
81
91
|
async def connect(self, websocket: WebSocket, session_id: str = "*",
|
|
82
|
-
uid: str = "", is_admin: bool = False
|
|
83
|
-
|
|
92
|
+
uid: str = "", is_admin: bool = False,
|
|
93
|
+
subprotocol: str | None = None) -> None:
|
|
94
|
+
# Echo the negotiated subprotocol ("bearer") when the token came in via
|
|
95
|
+
# the Sec-WebSocket-Protocol header, so the handshake completes cleanly.
|
|
96
|
+
await websocket.accept(subprotocol=subprotocol)
|
|
84
97
|
self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
|
|
85
98
|
|
|
86
99
|
def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
|
|
@@ -94,11 +107,11 @@ class ConnectionManager:
|
|
|
94
107
|
def sync_event_handler(self, event: SessionEvent) -> None:
|
|
95
108
|
"""Registered with SessionManager.subscribe(). Called on the event loop."""
|
|
96
109
|
payload = _serialize_event(event)
|
|
97
|
-
|
|
110
|
+
self._spawn(self._broadcast(event.session_id, payload, event.owner_uid))
|
|
98
111
|
|
|
99
112
|
def broadcast_scenario_event(self, uid: str, data: dict) -> None:
|
|
100
113
|
"""Schedule delivery to all WS connections for uid."""
|
|
101
|
-
|
|
114
|
+
self._spawn(self._broadcast_to_user(uid, data))
|
|
102
115
|
|
|
103
116
|
# ------------------------------------------------------------------
|
|
104
117
|
# Internal async broadcast
|
|
@@ -40,19 +40,35 @@ async def _send_snapshot(websocket: WebSocket, sm: SessionManager, uid: str, is_
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
43
|
+
def _token_from_ws(websocket: WebSocket, query_token: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
44
|
+
"""Resolve the JWT, preferring the Sec-WebSocket-Protocol header so the
|
|
45
|
+
token never appears in the URL (and thus not in access/proxy logs or
|
|
46
|
+
browser history). The browser sends ["bearer", "<jwt>"]; we echo "bearer"
|
|
47
|
+
as the negotiated subprotocol. Falls back to the legacy query param.
|
|
48
|
+
|
|
49
|
+
Returns (token, subprotocol_to_echo).
|
|
50
|
+
"""
|
|
51
|
+
raw = websocket.headers.get("sec-websocket-protocol", "")
|
|
52
|
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
|
53
|
+
if len(parts) >= 2 and parts[0] == "bearer":
|
|
54
|
+
return parts[1], "bearer"
|
|
55
|
+
return query_token, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[Optional[str], bool, Optional[str]]:
|
|
59
|
+
"""Validate the JWT. Returns (uid, is_admin, subprotocol) or (None, False, None)."""
|
|
60
|
+
token, subprotocol = _token_from_ws(websocket, token)
|
|
45
61
|
if not token:
|
|
46
|
-
return None, False
|
|
62
|
+
return None, False, None
|
|
47
63
|
try:
|
|
48
64
|
payload = decode_token(token)
|
|
49
65
|
uid = payload.get("sub", "")
|
|
50
66
|
role = payload.get("role", "")
|
|
51
67
|
if not uid:
|
|
52
|
-
return None, False
|
|
53
|
-
return uid, role == "platform_admin"
|
|
68
|
+
return None, False, None
|
|
69
|
+
return uid, role == "platform_admin", subprotocol
|
|
54
70
|
except JWTError:
|
|
55
|
-
return None, False
|
|
71
|
+
return None, False, None
|
|
56
72
|
|
|
57
73
|
|
|
58
74
|
@router.websocket("/ws")
|
|
@@ -63,7 +79,7 @@ async def ws_all_sessions(
|
|
|
63
79
|
token: Optional[str] = Query(default=None),
|
|
64
80
|
):
|
|
65
81
|
"""Subscribe to events from all sessions."""
|
|
66
|
-
uid, is_admin = _authenticate_ws(websocket, token)
|
|
82
|
+
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
67
83
|
if not uid:
|
|
68
84
|
logger.warning("WS /ws: auth failed (no token or invalid JWT)")
|
|
69
85
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
@@ -78,7 +94,8 @@ async def ws_all_sessions(
|
|
|
78
94
|
return
|
|
79
95
|
|
|
80
96
|
logger.info("WS /ws: client connected uid=%s is_admin=%s", uid, is_admin)
|
|
81
|
-
await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin
|
|
97
|
+
await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin,
|
|
98
|
+
subprotocol=subprotocol)
|
|
82
99
|
try:
|
|
83
100
|
await _send_snapshot(websocket, sm, uid, is_admin)
|
|
84
101
|
while True:
|
|
@@ -103,7 +120,7 @@ async def ws_single_session(
|
|
|
103
120
|
token: Optional[str] = Query(default=None),
|
|
104
121
|
):
|
|
105
122
|
"""Subscribe to events from a single session."""
|
|
106
|
-
uid, is_admin = _authenticate_ws(websocket, token)
|
|
123
|
+
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
107
124
|
if not uid:
|
|
108
125
|
logger.warning("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
|
|
109
126
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
@@ -125,7 +142,8 @@ async def ws_single_session(
|
|
|
125
142
|
return
|
|
126
143
|
|
|
127
144
|
logger.info("WS /ws/%s: client connected uid=%s is_admin=%s", session_id, uid, is_admin)
|
|
128
|
-
await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin
|
|
145
|
+
await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin,
|
|
146
|
+
subprotocol=subprotocol)
|
|
129
147
|
try:
|
|
130
148
|
await _send_snapshot(websocket, sm, uid, is_admin, session_id=session_id)
|
|
131
149
|
while True:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atomic JSON file writes shared by the JSON-backed stores.
|
|
3
|
+
|
|
4
|
+
Each store previously wrote to a fixed "<path>.tmp" then os.replace()'d it.
|
|
5
|
+
Two concurrent writers to the same path clobbered that single temp file,
|
|
6
|
+
so the bytes promoted by os.replace could be a corrupt interleaving. This
|
|
7
|
+
helper writes to a uniquely-named temp file in the same directory, fsyncs it,
|
|
8
|
+
then renames over the target — atomic and collision-free.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def atomic_write_json(path: str, data, *, indent: int = 2) -> None:
|
|
16
|
+
"""Atomically serialize `data` as JSON to `path`.
|
|
17
|
+
|
|
18
|
+
The temp file is created in the same directory as `path` (so os.replace is
|
|
19
|
+
a same-filesystem atomic rename) with a unique name (so concurrent writers
|
|
20
|
+
don't share a temp file). On any failure the temp file is removed.
|
|
21
|
+
"""
|
|
22
|
+
directory = os.path.dirname(path) or "."
|
|
23
|
+
os.makedirs(directory, exist_ok=True)
|
|
24
|
+
fd, tmp = tempfile.mkstemp(
|
|
25
|
+
prefix=os.path.basename(path) + ".", suffix=".tmp", dir=directory
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with os.fdopen(fd, "w") as f:
|
|
29
|
+
json.dump(data, f, indent=indent)
|
|
30
|
+
f.flush()
|
|
31
|
+
os.fsync(f.fileno())
|
|
32
|
+
os.replace(tmp, path)
|
|
33
|
+
except BaseException:
|
|
34
|
+
try:
|
|
35
|
+
os.unlink(tmp)
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
|
38
|
+
raise
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Password hashing and JWT helpers.
|
|
3
|
+
|
|
4
|
+
Secret key is read from the FIXTURE_SECRET_KEY environment variable.
|
|
5
|
+
If absent a random key is generated for the current process (tokens
|
|
6
|
+
won't survive a restart — acceptable for dev, not for production).
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
|
|
12
|
+
import bcrypt as _bcrypt
|
|
13
|
+
|
|
14
|
+
from jose import JWTError, jwt
|
|
15
|
+
|
|
16
|
+
from .user_store import User
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_ALGORITHM = "HS256"
|
|
21
|
+
_EXPIRY_SECONDS = 8 * 60 * 60 # 8 hours
|
|
22
|
+
|
|
23
|
+
# Set from FIXTURE_SECRET_KEY at import; otherwise resolved by init_secret_key()
|
|
24
|
+
# (persistent file) or, as a last resort, lazily generated by _get_secret().
|
|
25
|
+
_SECRET_KEY: str = os.environ.get("FIXTURE_SECRET_KEY", "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init_secret_key(data_dir: str) -> None:
|
|
29
|
+
"""Resolve a stable signing key when FIXTURE_SECRET_KEY is not set.
|
|
30
|
+
|
|
31
|
+
Loads (or creates) a persistent key file under data_dir so tokens survive
|
|
32
|
+
restarts in dev. Production should still set FIXTURE_SECRET_KEY explicitly
|
|
33
|
+
— it is required for multi-process deployments, where each worker would
|
|
34
|
+
otherwise generate its own key and reject each other's tokens.
|
|
35
|
+
"""
|
|
36
|
+
global _SECRET_KEY
|
|
37
|
+
if _SECRET_KEY:
|
|
38
|
+
return # explicit env var wins
|
|
39
|
+
|
|
40
|
+
key_path = os.path.join(data_dir, ".secret_key")
|
|
41
|
+
try:
|
|
42
|
+
if os.path.isfile(key_path):
|
|
43
|
+
with open(key_path) as f:
|
|
44
|
+
_SECRET_KEY = f.read().strip()
|
|
45
|
+
if not _SECRET_KEY:
|
|
46
|
+
_SECRET_KEY = secrets.token_hex(32)
|
|
47
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
48
|
+
# 0600 — the key must not be world-readable.
|
|
49
|
+
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
50
|
+
with os.fdopen(fd, "w") as f:
|
|
51
|
+
f.write(_SECRET_KEY)
|
|
52
|
+
logger.warning(
|
|
53
|
+
"FIXTURE_SECRET_KEY not set — generated a persistent key at %s. "
|
|
54
|
+
"Set FIXTURE_SECRET_KEY in production (required for multi-process).",
|
|
55
|
+
key_path,
|
|
56
|
+
)
|
|
57
|
+
except OSError as e:
|
|
58
|
+
logger.warning("Could not persist secret key (%s); falling back to ephemeral.", e)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_secret() -> str:
|
|
62
|
+
global _SECRET_KEY
|
|
63
|
+
if not _SECRET_KEY:
|
|
64
|
+
# Last resort: no env var and init_secret_key never ran / failed.
|
|
65
|
+
_SECRET_KEY = secrets.token_hex(32)
|
|
66
|
+
logger.warning(
|
|
67
|
+
"FIXTURE_SECRET_KEY not set — using a random ephemeral key. "
|
|
68
|
+
"Tokens will be invalidated on restart."
|
|
69
|
+
)
|
|
70
|
+
return _SECRET_KEY
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _encode(plain: str) -> bytes:
|
|
74
|
+
# bcrypt hard-limits at 72 bytes — truncate to avoid ValueError in bcrypt 5.x
|
|
75
|
+
return plain.encode("utf-8")[:72]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hash_password(plain: str) -> str:
|
|
79
|
+
return _bcrypt.hashpw(_encode(plain), _bcrypt.gensalt()).decode("utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
return _bcrypt.checkpw(_encode(plain), hashed.encode("utf-8"))
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_token(user: User) -> str:
|
|
90
|
+
import time
|
|
91
|
+
payload = {
|
|
92
|
+
"sub": user.uid,
|
|
93
|
+
"username": user.username,
|
|
94
|
+
"role": user.role,
|
|
95
|
+
"exp": int(time.time()) + _EXPIRY_SECONDS,
|
|
96
|
+
}
|
|
97
|
+
return jwt.encode(payload, _get_secret(), algorithm=_ALGORITHM)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decode_token(token: str) -> dict:
|
|
101
|
+
"""
|
|
102
|
+
Decode and validate a JWT. Raises jose.JWTError on invalid/expired token.
|
|
103
|
+
Returns the raw payload dict.
|
|
104
|
+
"""
|
|
105
|
+
return jwt.decode(token, _get_secret(), algorithms=[_ALGORITHM])
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
-
from typing import List
|
|
3
|
+
from typing import Dict, List
|
|
4
4
|
|
|
5
|
+
from .atomic_io import atomic_write_json
|
|
5
6
|
from .models import SessionConfig, ConnectionType, SessionRole
|
|
6
7
|
|
|
7
8
|
|
|
@@ -28,12 +29,28 @@ class ConfigStore:
|
|
|
28
29
|
def save_for_user(self, uid: str, configs: List[SessionConfig]) -> None:
|
|
29
30
|
"""Atomically save one user's sessions to data_dir/<uid>/sessions.json."""
|
|
30
31
|
user_dir = os.path.join(self._data_dir, uid)
|
|
31
|
-
os.makedirs(user_dir, exist_ok=True)
|
|
32
32
|
path = os.path.join(user_dir, "sessions.json")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
atomic_write_json(path, [_config_to_dict(c) for c in configs])
|
|
34
|
+
|
|
35
|
+
def save_all(self, configs_by_user: Dict[str, List[SessionConfig]]) -> None:
|
|
36
|
+
"""Persist every user's sessions and clear files for users with none left.
|
|
37
|
+
|
|
38
|
+
Reconciles against on-disk state: a user whose last session was removed
|
|
39
|
+
no longer appears in configs_by_user, so their sessions.json is emptied.
|
|
40
|
+
Without this, a deleted session reappears on the next load_all().
|
|
41
|
+
"""
|
|
42
|
+
for uid, configs in configs_by_user.items():
|
|
43
|
+
self.save_for_user(uid, configs)
|
|
44
|
+
|
|
45
|
+
if not os.path.isdir(self._data_dir):
|
|
46
|
+
return
|
|
47
|
+
for uid in os.listdir(self._data_dir):
|
|
48
|
+
if uid in configs_by_user:
|
|
49
|
+
continue
|
|
50
|
+
# Only rewrite users that already have a persisted file; skip
|
|
51
|
+
# non-user entries in data_dir (messages.db, logs, etc.).
|
|
52
|
+
if os.path.isfile(os.path.join(self._data_dir, uid, "sessions.json")):
|
|
53
|
+
self.save_for_user(uid, [])
|
|
37
54
|
|
|
38
55
|
|
|
39
56
|
def _load_file(path: str) -> List[SessionConfig]:
|
|
@@ -4,6 +4,8 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
from threading import Lock
|
|
6
6
|
|
|
7
|
+
from .atomic_io import atomic_write_json
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
class CustomTagStore:
|
|
9
11
|
def __init__(self, data_dir: str) -> None:
|
|
@@ -21,12 +23,7 @@ class CustomTagStore:
|
|
|
21
23
|
return {}
|
|
22
24
|
|
|
23
25
|
def _save(self, uid: str, tags: dict[int, dict]) -> None:
|
|
24
|
-
|
|
25
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
26
|
-
tmp = path + ".tmp"
|
|
27
|
-
with open(tmp, "w") as f:
|
|
28
|
-
json.dump({str(k): v for k, v in tags.items()}, f, indent=2)
|
|
29
|
-
os.replace(tmp, path)
|
|
26
|
+
atomic_write_json(self._path(uid), {str(k): v for k, v in tags.items()})
|
|
30
27
|
|
|
31
28
|
def list_tags(self, uid: str) -> list[dict]:
|
|
32
29
|
with self._lock:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import time
|
|
2
3
|
|
|
3
4
|
from fixcore.application import Application
|
|
4
5
|
from fixcore.message.message import Message
|
|
@@ -44,11 +45,21 @@ class FixApplication(Application):
|
|
|
44
45
|
pass
|
|
45
46
|
|
|
46
47
|
def from_admin(self, message: Message, session_id: SessionID) -> None:
|
|
47
|
-
|
|
48
|
+
recv_perf_ns = time.perf_counter_ns()
|
|
49
|
+
self._emit(EventType.MESSAGE_RECEIVED, {
|
|
50
|
+
"raw": message.encode().decode("ascii", errors="replace"),
|
|
51
|
+
"admin": True,
|
|
52
|
+
"recv_perf_ns": recv_perf_ns,
|
|
53
|
+
})
|
|
48
54
|
|
|
49
55
|
def from_app(self, message: Message, session_id: SessionID) -> None:
|
|
56
|
+
recv_perf_ns = time.perf_counter_ns()
|
|
50
57
|
try:
|
|
51
|
-
self._emit(EventType.MESSAGE_RECEIVED, {
|
|
58
|
+
self._emit(EventType.MESSAGE_RECEIVED, {
|
|
59
|
+
"raw": message.encode().decode("ascii", errors="replace"),
|
|
60
|
+
"admin": False,
|
|
61
|
+
"recv_perf_ns": recv_perf_ns,
|
|
62
|
+
})
|
|
52
63
|
except Exception:
|
|
53
64
|
pass
|
|
54
65
|
|
|
@@ -28,10 +28,15 @@ class HousekeepingService:
|
|
|
28
28
|
admin UI take effect on the next scheduled run without a restart.
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
-
def __init__(self, msg_db_path: str, log_dir: str, user_store: "UserStore"
|
|
31
|
+
def __init__(self, msg_db_path: str, log_dir: str, user_store: "UserStore",
|
|
32
|
+
message_writer=None):
|
|
32
33
|
self._msg_db_path = msg_db_path
|
|
33
34
|
self._log_dir = log_dir
|
|
34
35
|
self._user_store = user_store
|
|
36
|
+
# When present, purges are funneled through the single message writer so
|
|
37
|
+
# messages.db only ever has one writer. Falls back to its own connection
|
|
38
|
+
# when message logging is disabled (no writer).
|
|
39
|
+
self._message_writer = message_writer
|
|
35
40
|
|
|
36
41
|
def start(self) -> None:
|
|
37
42
|
"""Start the background daemon thread."""
|
|
@@ -80,9 +85,13 @@ class HousekeepingService:
|
|
|
80
85
|
def _purge_messages(self, retention_days: int) -> int:
|
|
81
86
|
if retention_days == 0:
|
|
82
87
|
return 0
|
|
88
|
+
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=retention_days)).isoformat()
|
|
89
|
+
if self._message_writer is not None:
|
|
90
|
+
# Single-writer path: the writer thread owns the only write connection.
|
|
91
|
+
return self._message_writer.purge(cutoff)
|
|
92
|
+
# Fallback: message logging disabled — no central writer, so no contention.
|
|
83
93
|
if not os.path.isfile(self._msg_db_path):
|
|
84
94
|
return 0
|
|
85
|
-
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=retention_days)).isoformat()
|
|
86
95
|
with sqlite3.connect(self._msg_db_path, timeout=30) as conn:
|
|
87
96
|
cur = conn.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
|
|
88
97
|
return cur.rowcount
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-session inbound message subscriptions.
|
|
3
|
+
|
|
4
|
+
This is the stable interface a high-rate consumer (e.g. the perf correlator)
|
|
5
|
+
builds against. A subscriber gets a *bounded* asyncio.Queue fed from the event
|
|
6
|
+
loop. The producer side (offer) is O(1) and never blocks the loop; when the
|
|
7
|
+
consumer falls behind and the buffer fills, the oldest message is evicted and a
|
|
8
|
+
`dropped` counter is incremented so loss is visible and measurable.
|
|
9
|
+
|
|
10
|
+
Threading: offer() and get() are both expected to run on the FastAPI/uvicorn
|
|
11
|
+
event loop (fixcore callbacks dispatch there; perf consumers are loop tasks).
|
|
12
|
+
asyncio.Queue is not thread-safe — do not call these from a worker thread.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .session_manager import SessionManager
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAXSIZE = 10_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class InboundMessage:
|
|
29
|
+
"""A single inbound (received) FIX message handed to a subscriber."""
|
|
30
|
+
session_id: str
|
|
31
|
+
admin: bool # True = session-level (Logon, Heartbeat, ...)
|
|
32
|
+
raw: str # raw FIX string (SOH-delimited)
|
|
33
|
+
msg_type: str # MsgType (tag 35)
|
|
34
|
+
seq_num: int # MsgSeqNum (tag 34); 0 if absent
|
|
35
|
+
fields: dict # {tag_int: value_str}
|
|
36
|
+
recv_perf_ns: int # time.perf_counter_ns() at fixcore delivery
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionSubscription:
|
|
40
|
+
"""
|
|
41
|
+
Bounded, drop-oldest feed of inbound messages for one session.
|
|
42
|
+
|
|
43
|
+
Producer (event loop) calls offer(); consumer (a perf correlator task)
|
|
44
|
+
awaits get(). Close via close() or use as a context manager to deregister.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, manager: "SessionManager", session_id: str,
|
|
48
|
+
maxsize: int = DEFAULT_MAXSIZE):
|
|
49
|
+
self._manager = manager
|
|
50
|
+
self.session_id = session_id
|
|
51
|
+
self._queue: "asyncio.Queue[InboundMessage]" = asyncio.Queue(maxsize=maxsize)
|
|
52
|
+
self.dropped = 0
|
|
53
|
+
self._closed = False
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Producer side (event loop)
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def offer(self, msg: InboundMessage) -> None:
|
|
60
|
+
"""Enqueue a message. O(1), never blocks. Evicts oldest when full."""
|
|
61
|
+
try:
|
|
62
|
+
self._queue.put_nowait(msg)
|
|
63
|
+
except asyncio.QueueFull:
|
|
64
|
+
try:
|
|
65
|
+
self._queue.get_nowait() # evict oldest
|
|
66
|
+
except asyncio.QueueEmpty:
|
|
67
|
+
pass
|
|
68
|
+
self.dropped += 1
|
|
69
|
+
try:
|
|
70
|
+
self._queue.put_nowait(msg)
|
|
71
|
+
except asyncio.QueueFull:
|
|
72
|
+
# Should not happen on a single-threaded loop, but stay safe.
|
|
73
|
+
self.dropped += 1
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Consumer side
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async def get(self) -> InboundMessage:
|
|
80
|
+
"""Await the next inbound message."""
|
|
81
|
+
return await self._queue.get()
|
|
82
|
+
|
|
83
|
+
def get_nowait(self) -> InboundMessage:
|
|
84
|
+
"""Return the next message or raise asyncio.QueueEmpty."""
|
|
85
|
+
return self._queue.get_nowait()
|
|
86
|
+
|
|
87
|
+
def qsize(self) -> int:
|
|
88
|
+
return self._queue.qsize()
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Lifecycle
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Deregister from the manager. Idempotent."""
|
|
96
|
+
if not self._closed:
|
|
97
|
+
self._closed = True
|
|
98
|
+
self._manager._remove_subscription(self)
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> "SessionSubscription":
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *exc) -> None:
|
|
104
|
+
self.close()
|