fixtureqa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. fixture/__init__.py +22 -0
  2. fixture/__main__.py +161 -0
  3. fixture/api/__init__.py +0 -0
  4. fixture/api/app.py +95 -0
  5. fixture/api/connection_manager.py +161 -0
  6. fixture/api/deps.py +73 -0
  7. fixture/api/routers/__init__.py +0 -0
  8. fixture/api/routers/admin.py +178 -0
  9. fixture/api/routers/auth.py +74 -0
  10. fixture/api/routers/branding.py +33 -0
  11. fixture/api/routers/fix_spec.py +41 -0
  12. fixture/api/routers/messages.py +137 -0
  13. fixture/api/routers/scenarios.py +65 -0
  14. fixture/api/routers/sessions.py +272 -0
  15. fixture/api/routers/setup.py +42 -0
  16. fixture/api/routers/templates.py +36 -0
  17. fixture/api/routers/ws.py +129 -0
  18. fixture/api/schemas.py +289 -0
  19. fixture/config/__init__.py +0 -0
  20. fixture/core/__init__.py +0 -0
  21. fixture/core/auth.py +68 -0
  22. fixture/core/config_store.py +85 -0
  23. fixture/core/events.py +22 -0
  24. fixture/core/fix_application.py +67 -0
  25. fixture/core/fix_parser.py +79 -0
  26. fixture/core/fix_spec_parser.py +172 -0
  27. fixture/core/fix_tags.py +297 -0
  28. fixture/core/housekeeping.py +107 -0
  29. fixture/core/message_log.py +115 -0
  30. fixture/core/message_store.py +246 -0
  31. fixture/core/models.py +87 -0
  32. fixture/core/scenario_runner.py +331 -0
  33. fixture/core/scenario_store.py +70 -0
  34. fixture/core/session.py +278 -0
  35. fixture/core/session_manager.py +173 -0
  36. fixture/core/template_store.py +70 -0
  37. fixture/core/user_store.py +186 -0
  38. fixture/core/venue_responses.py +94 -0
  39. fixture/fix_specs/FIX42.xml +2746 -0
  40. fixture/fix_specs/FIX44.xml +6593 -0
  41. fixture/server.py +37 -0
  42. fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  43. fixture/static/assets/index-B31-1dt-.css +1 -0
  44. fixture/static/assets/index-CTsKxGdI.js +87 -0
  45. fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  46. fixture/static/favicon.svg +12 -0
  47. fixture/static/index.html +15 -0
  48. fixture/ui/__init__.py +0 -0
  49. fixtureqa-0.1.0.dist-info/METADATA +16 -0
  50. fixtureqa-0.1.0.dist-info/RECORD +54 -0
  51. fixtureqa-0.1.0.dist-info/WHEEL +5 -0
  52. fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
  53. fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
fixture/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from .core.session_manager import SessionManager
2
+ from .core.models import SessionConfig, SessionState, SessionStatus, ConnectionType
3
+ from .core.events import SessionEvent, EventType
4
+ from .core.message_log import MessageLog, LogEntry
5
+ from .core.fix_parser import parse_raw, pretty_print
6
+ from .core.fix_tags import tag_name, msg_type_name
7
+
8
+ __all__ = [
9
+ "SessionManager",
10
+ "SessionConfig",
11
+ "SessionState",
12
+ "SessionStatus",
13
+ "ConnectionType",
14
+ "SessionEvent",
15
+ "EventType",
16
+ "MessageLog",
17
+ "LogEntry",
18
+ "parse_raw",
19
+ "pretty_print",
20
+ "tag_name",
21
+ "msg_type_name",
22
+ ]
fixture/__main__.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ python -m fixture [--host HOST] [--port PORT]
3
+ """
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import logging.handlers
9
+ import os
10
+ import time
11
+
12
+ from .core.session_manager import SessionManager
13
+ from .core.config_store import ConfigStore
14
+ from .server import start_server
15
+
16
+ logger = logging.getLogger("fixture")
17
+
18
+
19
+ def _configure_logging(level_name: str, log_file: str | None = None) -> None:
20
+ """
21
+ Configure the root logger and (optionally) a rotating file handler.
22
+
23
+ Passing log_config=None to uvicorn.Config prevents uvicorn from
24
+ overriding this setup, so uvicorn's own loggers propagate here too.
25
+ """
26
+ level = getattr(logging, level_name.upper(), logging.INFO)
27
+ fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
28
+ datefmt = "%Y-%m-%d %H:%M:%S"
29
+ formatter = logging.Formatter(fmt, datefmt)
30
+
31
+ root = logging.getLogger()
32
+ root.setLevel(level)
33
+
34
+ # Stdout handler
35
+ sh = logging.StreamHandler()
36
+ sh.setFormatter(formatter)
37
+ root.addHandler(sh)
38
+
39
+ # Optional rotating file handler
40
+ if log_file:
41
+ fh = logging.handlers.RotatingFileHandler(
42
+ log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
43
+ )
44
+ fh.setFormatter(formatter)
45
+ root.addHandler(fh)
46
+
47
+
48
+ def _migrate_legacy(sessions_file: str, data_dir: str, db_path: str) -> None:
49
+ """
50
+ One-time migration: read a legacy sessions.json, group by owner_uid,
51
+ write to data_dir/<uid>/sessions.json, then rename the legacy file.
52
+ Sessions with no owner_uid are assigned to the platform_admin.
53
+ """
54
+ if not os.path.isfile(sessions_file):
55
+ return
56
+
57
+ # Check if data_dir already has any sessions (migration already done)
58
+ if os.path.isdir(data_dir) and any(
59
+ os.path.isfile(os.path.join(data_dir, uid, "sessions.json"))
60
+ for uid in os.listdir(data_dir)
61
+ if os.path.isdir(os.path.join(data_dir, uid))
62
+ ):
63
+ return
64
+
65
+ logger.info("Migrating %s → %s/<uid>/sessions.json ...", sessions_file, data_dir)
66
+
67
+ try:
68
+ with open(sessions_file) as f:
69
+ data = json.load(f)
70
+ except (OSError, json.JSONDecodeError) as e:
71
+ logger.warning("Migration skipped: could not read %s: %s", sessions_file, e)
72
+ return
73
+
74
+ # Find the platform_admin uid to assign orphaned sessions
75
+ fallback_uid = _get_platform_admin_uid(db_path)
76
+
77
+ by_user: dict = {}
78
+ for entry in data:
79
+ uid = entry.get("owner_uid") or fallback_uid
80
+ if not uid:
81
+ continue
82
+ # Remove owner_uid from stored JSON (canonical from directory name)
83
+ entry.pop("owner_uid", None)
84
+ by_user.setdefault(uid, []).append(entry)
85
+
86
+ for uid, entries in by_user.items():
87
+ user_dir = os.path.join(data_dir, uid)
88
+ os.makedirs(user_dir, exist_ok=True)
89
+ path = os.path.join(user_dir, "sessions.json")
90
+ tmp = path + ".tmp"
91
+ with open(tmp, "w") as f:
92
+ json.dump(entries, f, indent=2)
93
+ os.replace(tmp, path)
94
+ logger.info(" Wrote %d session(s) for user %s", len(entries), uid)
95
+
96
+ migrated = sessions_file + ".migrated"
97
+ os.rename(sessions_file, migrated)
98
+ logger.info("Migration complete. Legacy file renamed to %s", migrated)
99
+
100
+
101
+ def _get_platform_admin_uid(db_path: str) -> str:
102
+ """Return the uid of the first platform_admin, or '' if not found."""
103
+ try:
104
+ import sqlite3
105
+ conn = sqlite3.connect(db_path)
106
+ row = conn.execute(
107
+ "SELECT uid FROM users WHERE role='platform_admin' ORDER BY created_at LIMIT 1"
108
+ ).fetchone()
109
+ conn.close()
110
+ return row[0] if row else ""
111
+ except Exception:
112
+ return ""
113
+
114
+
115
+ def main():
116
+ parser = argparse.ArgumentParser(description="FIXture FIX testing tool")
117
+ parser.add_argument("--host", default="0.0.0.0")
118
+ parser.add_argument("--port", type=int, default=8000)
119
+ parser.add_argument("--log-level", default="info")
120
+ parser.add_argument("--log-file", default=None,
121
+ help="Path for rotating log file (default: <data-dir>/fixture.log)")
122
+ parser.add_argument("--data-dir", default="./data",
123
+ help="Directory for per-user session data")
124
+ parser.add_argument("--db-path", default="./users.db",
125
+ help="Path to SQLite database for user accounts")
126
+ args = parser.parse_args()
127
+
128
+ # Ensure data dir exists before opening the log file inside it
129
+ os.makedirs(args.data_dir, exist_ok=True)
130
+
131
+ # Default log file lives next to the other data files
132
+ log_file = args.log_file or os.path.join(args.data_dir, "fixture.log")
133
+
134
+ # Configure logging before anything else so all modules inherit the setup
135
+ _configure_logging(args.log_level, log_file)
136
+
137
+ # Auto-migrate legacy sessions.json if present
138
+ _migrate_legacy("./sessions.json", args.data_dir, args.db_path)
139
+
140
+ store = ConfigStore(args.data_dir)
141
+ msg_db_path = os.path.join(args.data_dir, "messages.db")
142
+ sm = SessionManager(config_store=store, msg_db_path=msg_db_path)
143
+ sm.load_persisted()
144
+ server = start_server(sm, host=args.host, port=args.port, log_level=args.log_level,
145
+ db_path=args.db_path, msg_db_path=msg_db_path, log_dir="./log",
146
+ data_dir=args.data_dir)
147
+
148
+ logger.info("FIXture running at http://%s:%s", args.host, args.port)
149
+
150
+ try:
151
+ while not server.should_exit:
152
+ time.sleep(0.5)
153
+ except KeyboardInterrupt:
154
+ pass
155
+ finally:
156
+ server.should_exit = True
157
+ logger.info("Shutting down...")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
File without changes
fixture/api/app.py ADDED
@@ -0,0 +1,95 @@
1
+ import os
2
+ from contextlib import asynccontextmanager
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.responses import FileResponse
7
+
8
+ from ..core.housekeeping import HousekeepingService
9
+ from ..core.session_manager import SessionManager
10
+ from ..core.scenario_store import ScenarioStore
11
+ from ..core.scenario_runner import ScenarioRunner
12
+ from ..core.template_store import TemplateStore
13
+ from ..core.user_store import UserStore
14
+ from .connection_manager import ConnectionManager
15
+ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ # Register event bridge with SessionManager
21
+ sm: SessionManager = app.state.session_manager
22
+ cm: ConnectionManager = app.state.connection_manager
23
+ sm.subscribe(cm.sync_event_handler)
24
+ # Start background housekeeping thread
25
+ app.state.housekeeping.start()
26
+ yield
27
+ sm.unsubscribe(cm.sync_event_handler)
28
+ sm.close() # flush SQLite writer
29
+
30
+
31
+ def create_app(
32
+ session_manager: SessionManager,
33
+ db_path: str = "./users.db",
34
+ msg_db_path: str = "./data/messages.db",
35
+ log_dir: str = "./log",
36
+ data_dir: str = "./data",
37
+ ) -> FastAPI:
38
+ app = FastAPI(
39
+ title="FIXture",
40
+ description="FIX Protocol Testing Tool",
41
+ version="0.1.0",
42
+ lifespan=lifespan,
43
+ )
44
+
45
+ # Store singletons on app state (injected via Depends)
46
+ app.state.session_manager = session_manager
47
+ app.state.connection_manager = ConnectionManager()
48
+ user_store = UserStore(db_path)
49
+ app.state.user_store = user_store
50
+ app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
51
+ app.state.template_store = TemplateStore(data_dir)
52
+ app.state.scenario_store = ScenarioStore(data_dir)
53
+ conn_manager: ConnectionManager = app.state.connection_manager
54
+ app.state.scenario_runner = ScenarioRunner(
55
+ session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
56
+ )
57
+
58
+ # API routers
59
+ app.include_router(auth.router, prefix="/api/auth")
60
+ app.include_router(setup.router, prefix="/api/setup")
61
+ app.include_router(admin.router, prefix="/api/admin")
62
+ app.include_router(sessions.router, prefix="/api/sessions")
63
+ app.include_router(messages.router, prefix="/api/sessions")
64
+ app.include_router(fix_spec.router, prefix="/api/fix-spec")
65
+ app.include_router(templates.router, prefix="/api/templates")
66
+ app.include_router(scenarios.router, prefix="/api/scenarios")
67
+ app.include_router(branding.router, prefix="/api")
68
+ app.include_router(ws.router)
69
+
70
+ # Serve built React app from fixture/static/
71
+ static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
72
+ static_dir = os.path.abspath(static_dir)
73
+
74
+ if os.path.isdir(static_dir) and os.listdir(static_dir):
75
+ # Serve hashed JS/CSS bundles
76
+ app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
77
+
78
+ # Serve favicon explicitly
79
+ @app.get("/favicon.svg")
80
+ async def favicon():
81
+ return FileResponse(os.path.join(static_dir, "favicon.svg"))
82
+
83
+ # Catch-all: return index.html for all SPA routes (/login, /dashboard, etc.)
84
+ @app.get("/{path_name:path}")
85
+ async def serve_spa(path_name: str):
86
+ return FileResponse(os.path.join(static_dir, "index.html"))
87
+ else:
88
+ # Frontend not built yet — serve a placeholder
89
+ @app.get("/")
90
+ def root():
91
+ return {
92
+ "message": "FIXture API is running. Build the frontend with: cd frontend && npm run build"
93
+ }
94
+
95
+ return app
@@ -0,0 +1,161 @@
1
+ """
2
+ Bridges the SessionManager event system to async WebSocket clients.
3
+
4
+ fixcore callbacks fire on the asyncio event loop — no thread-crossing needed.
5
+ """
6
+
7
+ import asyncio
8
+ from datetime import datetime, timezone
9
+
10
+ from fastapi import WebSocket
11
+ from starlette.websockets import WebSocketState
12
+
13
+ from ..core.events import SessionEvent, EventType
14
+ from ..core.message_log import LogEntry
15
+
16
+
17
+ def _now_iso() -> str:
18
+ return datetime.now(tz=timezone.utc).isoformat()
19
+
20
+
21
+ def _serialize_event(event: SessionEvent) -> dict:
22
+ p = event.payload
23
+ if event.event_type == EventType.SESSION_STATUS_CHANGED:
24
+ return {
25
+ "type": "session_status",
26
+ "session_id": event.session_id,
27
+ "status": p.get("status"),
28
+ "status_changed_at": p.get("status_changed_at"),
29
+ "last_heartbeat_at": p.get("last_heartbeat_at"),
30
+ "ts": _now_iso(),
31
+ }
32
+ elif event.event_type in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
33
+ return {
34
+ "type": "message",
35
+ "session_id": event.session_id,
36
+ "direction": "IN" if event.event_type == EventType.MESSAGE_RECEIVED else "OUT",
37
+ "admin": p.get("admin", False),
38
+ "seq_num": p.get("seq_num", 0),
39
+ "msg_type": p.get("msg_type", ""),
40
+ "msg_type_name": p.get("msg_type_name", ""),
41
+ "ts": _now_iso(),
42
+ "fields": {str(k): v for k, v in p.get("fields", {}).items()},
43
+ "raw": p.get("raw", ""),
44
+ }
45
+ elif event.event_type == EventType.ENGINE_ERROR:
46
+ return {
47
+ "type": "engine_error",
48
+ "session_id": event.session_id,
49
+ "error": p.get("error", ""),
50
+ "ts": _now_iso(),
51
+ }
52
+ return {"type": "unknown", "session_id": event.session_id, "ts": _now_iso()}
53
+
54
+
55
+ class _ConnInfo:
56
+ __slots__ = ("uid", "is_admin")
57
+
58
+ def __init__(self, uid: str, is_admin: bool):
59
+ self.uid = uid
60
+ self.is_admin = is_admin
61
+
62
+
63
+ class ConnectionManager:
64
+ """
65
+ Tracks all active WebSocket connections and fans out events to them.
66
+
67
+ Connections can subscribe to a specific session_id or to all sessions ("*").
68
+ Each connection carries a uid and is_admin flag for broadcast filtering.
69
+ """
70
+
71
+ def __init__(self):
72
+ # session_id (or "*") -> dict[WebSocket, _ConnInfo]
73
+ self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
74
+
75
+ # ------------------------------------------------------------------
76
+ # Called from async context (WS handler)
77
+ # ------------------------------------------------------------------
78
+
79
+ async def connect(self, websocket: WebSocket, session_id: str = "*",
80
+ uid: str = "", is_admin: bool = False) -> None:
81
+ await websocket.accept()
82
+ with self._lock:
83
+ self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
84
+
85
+ def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
86
+ with self._lock:
87
+ clients = self._clients.get(session_id, {})
88
+ clients.pop(websocket, None)
89
+
90
+ # ------------------------------------------------------------------
91
+ # Called from the asyncio event loop (fixcore callbacks / scenario runner)
92
+ # ------------------------------------------------------------------
93
+
94
+ def sync_event_handler(self, event: SessionEvent) -> None:
95
+ """Registered with SessionManager.subscribe(). Called on the event loop."""
96
+ payload = _serialize_event(event)
97
+ asyncio.create_task(self._broadcast(event.session_id, payload, event.owner_uid))
98
+
99
+ def broadcast_scenario_event(self, uid: str, data: dict) -> None:
100
+ """Schedule delivery to all WS connections for uid."""
101
+ asyncio.create_task(self._broadcast_to_user(uid, data))
102
+
103
+ # ------------------------------------------------------------------
104
+ # Internal async broadcast
105
+ # ------------------------------------------------------------------
106
+
107
+ async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
108
+ with self._lock:
109
+ # Specific-session subscribers
110
+ specific = dict(self._clients.get(session_id, {}))
111
+ # Wildcard subscribers
112
+ wildcard = dict(self._clients.get("*", {}))
113
+
114
+ # Build target set: connections that may receive this event
115
+ # A connection receives the event if:
116
+ # - it is an admin, OR
117
+ # - its uid matches the session owner
118
+ targets: dict[WebSocket, _ConnInfo] = {}
119
+ for ws, info in {**specific, **wildcard}.items():
120
+ if info.is_admin or info.uid == owner_uid:
121
+ targets[ws] = info
122
+
123
+ dead: list[tuple[WebSocket, str]] = []
124
+ for ws in targets:
125
+ try:
126
+ if ws.client_state == WebSocketState.CONNECTED:
127
+ await ws.send_json(payload)
128
+ except Exception:
129
+ with self._lock:
130
+ for key, sockets in self._clients.items():
131
+ if ws in sockets:
132
+ dead.append((ws, key))
133
+
134
+ with self._lock:
135
+ for ws, key in dead:
136
+ self._clients.get(key, {}).pop(ws, None)
137
+
138
+ async def _broadcast_to_user(self, uid: str, data: dict) -> None:
139
+ """Send data to all WS connections belonging to uid."""
140
+ with self._lock:
141
+ all_clients = {}
142
+ for sockets in self._clients.values():
143
+ for ws, info in sockets.items():
144
+ all_clients[ws] = info
145
+
146
+ dead: list[tuple[WebSocket, str]] = []
147
+ for ws, info in all_clients.items():
148
+ if info.uid != uid and not info.is_admin:
149
+ continue
150
+ try:
151
+ if ws.client_state == WebSocketState.CONNECTED:
152
+ await ws.send_json(data)
153
+ except Exception:
154
+ with self._lock:
155
+ for key, sockets in self._clients.items():
156
+ if ws in sockets:
157
+ dead.append((ws, key))
158
+
159
+ with self._lock:
160
+ for ws, key in dead:
161
+ self._clients.get(key, {}).pop(ws, None)
fixture/api/deps.py ADDED
@@ -0,0 +1,73 @@
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer
3
+ from jose import JWTError
4
+ from starlette.requests import HTTPConnection
5
+
6
+ from ..core.session_manager import SessionManager
7
+ from ..core.user_store import User, UserStore
8
+ from ..core.auth import decode_token
9
+ from ..core.housekeeping import HousekeepingService
10
+ from ..core.template_store import TemplateStore
11
+ from ..core.scenario_store import ScenarioStore
12
+ from ..core.scenario_runner import ScenarioRunner
13
+ from .connection_manager import ConnectionManager
14
+
15
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
16
+
17
+
18
+ def get_session_manager(request: HTTPConnection) -> SessionManager:
19
+ return request.app.state.session_manager
20
+
21
+
22
+ def get_conn_manager(request: HTTPConnection) -> ConnectionManager:
23
+ return request.app.state.connection_manager
24
+
25
+
26
+ def get_user_store(request: HTTPConnection) -> UserStore:
27
+ return request.app.state.user_store
28
+
29
+
30
+ def get_current_user(
31
+ request: HTTPConnection,
32
+ token: str = Depends(oauth2_scheme),
33
+ ) -> User:
34
+ credentials_exception = HTTPException(
35
+ status_code=status.HTTP_401_UNAUTHORIZED,
36
+ detail="Invalid or expired token",
37
+ headers={"WWW-Authenticate": "Bearer"},
38
+ )
39
+ try:
40
+ payload = decode_token(token)
41
+ uid: str = payload.get("sub", "")
42
+ if not uid:
43
+ raise credentials_exception
44
+ except JWTError:
45
+ raise credentials_exception
46
+
47
+ store: UserStore = request.app.state.user_store
48
+ user = store.get_by_uid(uid)
49
+ if user is None or not user.is_active:
50
+ raise credentials_exception
51
+ return user
52
+
53
+
54
+ def require_platform_admin(user: User = Depends(get_current_user)) -> User:
55
+ if user.role != "platform_admin":
56
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
57
+ return user
58
+
59
+
60
+ def get_housekeeping(request: HTTPConnection) -> HousekeepingService:
61
+ return request.app.state.housekeeping
62
+
63
+
64
+ def get_template_store(request: HTTPConnection) -> TemplateStore:
65
+ return request.app.state.template_store
66
+
67
+
68
+ def get_scenario_store(request: HTTPConnection) -> ScenarioStore:
69
+ return request.app.state.scenario_store
70
+
71
+
72
+ def get_scenario_runner(request: HTTPConnection) -> ScenarioRunner:
73
+ return request.app.state.scenario_runner
File without changes