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.
- fixture/__init__.py +22 -0
- fixture/__main__.py +161 -0
- fixture/api/__init__.py +0 -0
- fixture/api/app.py +95 -0
- fixture/api/connection_manager.py +161 -0
- fixture/api/deps.py +73 -0
- fixture/api/routers/__init__.py +0 -0
- fixture/api/routers/admin.py +178 -0
- fixture/api/routers/auth.py +74 -0
- fixture/api/routers/branding.py +33 -0
- fixture/api/routers/fix_spec.py +41 -0
- fixture/api/routers/messages.py +137 -0
- fixture/api/routers/scenarios.py +65 -0
- fixture/api/routers/sessions.py +272 -0
- fixture/api/routers/setup.py +42 -0
- fixture/api/routers/templates.py +36 -0
- fixture/api/routers/ws.py +129 -0
- fixture/api/schemas.py +289 -0
- fixture/config/__init__.py +0 -0
- fixture/core/__init__.py +0 -0
- fixture/core/auth.py +68 -0
- fixture/core/config_store.py +85 -0
- fixture/core/events.py +22 -0
- fixture/core/fix_application.py +67 -0
- fixture/core/fix_parser.py +79 -0
- fixture/core/fix_spec_parser.py +172 -0
- fixture/core/fix_tags.py +297 -0
- fixture/core/housekeeping.py +107 -0
- fixture/core/message_log.py +115 -0
- fixture/core/message_store.py +246 -0
- fixture/core/models.py +87 -0
- fixture/core/scenario_runner.py +331 -0
- fixture/core/scenario_store.py +70 -0
- fixture/core/session.py +278 -0
- fixture/core/session_manager.py +173 -0
- fixture/core/template_store.py +70 -0
- fixture/core/user_store.py +186 -0
- fixture/core/venue_responses.py +94 -0
- fixture/fix_specs/FIX42.xml +2746 -0
- fixture/fix_specs/FIX44.xml +6593 -0
- fixture/server.py +37 -0
- fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
- fixture/static/assets/index-B31-1dt-.css +1 -0
- fixture/static/assets/index-CTsKxGdI.js +87 -0
- fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
- fixture/static/favicon.svg +12 -0
- fixture/static/index.html +15 -0
- fixture/ui/__init__.py +0 -0
- fixtureqa-0.1.0.dist-info/METADATA +16 -0
- fixtureqa-0.1.0.dist-info/RECORD +54 -0
- fixtureqa-0.1.0.dist-info/WHEEL +5 -0
- fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
- fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
fixture/api/schemas.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SessionConfigRequest(BaseModel):
|
|
6
|
+
display_name: str = ""
|
|
7
|
+
begin_string: str = "FIX.4.2"
|
|
8
|
+
sender_comp_id: str
|
|
9
|
+
target_comp_id: str
|
|
10
|
+
connection_type: str = "initiator" # "initiator" or "acceptor"
|
|
11
|
+
host: str = "localhost"
|
|
12
|
+
port: int = Field(default=9876, ge=1, le=65535)
|
|
13
|
+
heartbeat_interval: int = Field(default=30, ge=1)
|
|
14
|
+
reconnect_interval: int = Field(default=10, ge=1)
|
|
15
|
+
data_dictionary_path: Optional[str] = None
|
|
16
|
+
use_file_log: bool = True
|
|
17
|
+
start_time: str = "00:00:00"
|
|
18
|
+
end_time: str = "00:00:00"
|
|
19
|
+
reset_on_logon: bool = True
|
|
20
|
+
reset_on_logout: bool = False
|
|
21
|
+
session_role: str = "generic" # "client", "venue", or "generic"
|
|
22
|
+
auto_ack: bool = False
|
|
23
|
+
auto_ack_delay_ms: int = Field(default=100, ge=0)
|
|
24
|
+
auto_fill: bool = False
|
|
25
|
+
auto_fill_delay_ms: int = Field(default=500, ge=0)
|
|
26
|
+
auto_start: bool = False # start engine immediately after adding
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionResponse(BaseModel):
|
|
30
|
+
session_id: str
|
|
31
|
+
display_name: str
|
|
32
|
+
begin_string: str
|
|
33
|
+
sender_comp_id: str
|
|
34
|
+
target_comp_id: str
|
|
35
|
+
connection_type: str
|
|
36
|
+
host: str
|
|
37
|
+
port: int
|
|
38
|
+
heartbeat_interval: int
|
|
39
|
+
reconnect_interval: int
|
|
40
|
+
use_file_log: bool
|
|
41
|
+
start_time: str
|
|
42
|
+
end_time: str
|
|
43
|
+
reset_on_logon: bool
|
|
44
|
+
reset_on_logout: bool
|
|
45
|
+
session_role: str
|
|
46
|
+
auto_ack: bool
|
|
47
|
+
auto_ack_delay_ms: int
|
|
48
|
+
auto_fill: bool
|
|
49
|
+
auto_fill_delay_ms: int
|
|
50
|
+
data_dictionary_path: Optional[str]
|
|
51
|
+
status: str
|
|
52
|
+
last_sent_seq_num: int
|
|
53
|
+
last_recv_seq_num: int
|
|
54
|
+
message_count: int
|
|
55
|
+
status_changed_at: Optional[float] = None
|
|
56
|
+
last_heartbeat_at: Optional[float] = None
|
|
57
|
+
owner_username: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UpdateSessionRequest(BaseModel):
|
|
61
|
+
display_name: Optional[str] = None
|
|
62
|
+
begin_string: Optional[str] = None
|
|
63
|
+
sender_comp_id: Optional[str] = None
|
|
64
|
+
target_comp_id: Optional[str] = None
|
|
65
|
+
connection_type: Optional[str] = None
|
|
66
|
+
host: Optional[str] = None
|
|
67
|
+
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
|
68
|
+
heartbeat_interval: Optional[int] = Field(default=None, ge=1)
|
|
69
|
+
reconnect_interval: Optional[int] = Field(default=None, ge=1)
|
|
70
|
+
data_dictionary_path: Optional[str] = None
|
|
71
|
+
use_file_log: Optional[bool] = None
|
|
72
|
+
start_time: Optional[str] = None
|
|
73
|
+
end_time: Optional[str] = None
|
|
74
|
+
session_role: Optional[str] = None
|
|
75
|
+
auto_ack: Optional[bool] = None
|
|
76
|
+
auto_ack_delay_ms: Optional[int] = Field(default=None, ge=0)
|
|
77
|
+
auto_fill: Optional[bool] = None
|
|
78
|
+
auto_fill_delay_ms: Optional[int] = Field(default=None, ge=0)
|
|
79
|
+
reset_on_logon: Optional[bool] = None
|
|
80
|
+
reset_on_logout: Optional[bool] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MessageEntryResponse(BaseModel):
|
|
84
|
+
id: Optional[int] = None # SQLite rowid; None for in-memory entries
|
|
85
|
+
direction: str
|
|
86
|
+
admin: bool
|
|
87
|
+
seq_num: int
|
|
88
|
+
msg_type: str
|
|
89
|
+
msg_type_name: str
|
|
90
|
+
ts: str
|
|
91
|
+
fields: dict[str, str]
|
|
92
|
+
raw: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SendMessageRequest(BaseModel):
|
|
96
|
+
raw: str # raw SOH-delimited or pipe-delimited FIX string
|
|
97
|
+
no_auto_ts: bool = False # if True, skip auto-injection of tag 60 TransactTime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SeqNumRequest(BaseModel):
|
|
101
|
+
sender: Optional[int] = Field(default=None, ge=1)
|
|
102
|
+
target: Optional[int] = Field(default=None, ge=1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SeqNumResponse(BaseModel):
|
|
106
|
+
sender: int # next TX seqnum
|
|
107
|
+
target: int # next RX seqnum
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LoginRequest(BaseModel):
|
|
111
|
+
username: str
|
|
112
|
+
password: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class UserResponse(BaseModel):
|
|
116
|
+
uid: str
|
|
117
|
+
username: str
|
|
118
|
+
role: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TokenResponse(BaseModel):
|
|
122
|
+
access_token: str
|
|
123
|
+
token_type: str = "bearer"
|
|
124
|
+
user: UserResponse
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SetupStatusResponse(BaseModel):
|
|
128
|
+
setup_required: bool
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CreateAdminRequest(BaseModel):
|
|
132
|
+
username: str
|
|
133
|
+
password: str
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UserDetailResponse(BaseModel):
|
|
137
|
+
uid: str
|
|
138
|
+
username: str
|
|
139
|
+
role: str
|
|
140
|
+
is_active: bool
|
|
141
|
+
created_at: float
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CreateUserRequest(BaseModel):
|
|
145
|
+
username: str
|
|
146
|
+
password: str
|
|
147
|
+
role: str = "user"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class UpdateUserRequest(BaseModel):
|
|
151
|
+
is_active: Optional[bool] = None
|
|
152
|
+
role: Optional[str] = None
|
|
153
|
+
password: Optional[str] = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RegistrationStatusResponse(BaseModel):
|
|
157
|
+
enabled: bool
|
|
158
|
+
max_users: int
|
|
159
|
+
current_users: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RegisterRequest(BaseModel):
|
|
163
|
+
username: str
|
|
164
|
+
password: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AdminSettingsRequest(BaseModel):
|
|
168
|
+
registration_enabled: Optional[bool] = None
|
|
169
|
+
max_users: Optional[int] = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class HousekeepingSettingsResponse(BaseModel):
|
|
173
|
+
enabled: bool
|
|
174
|
+
msg_retention_days: int
|
|
175
|
+
log_retention_days: int
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class HousekeepingSettingsRequest(BaseModel):
|
|
179
|
+
enabled: Optional[bool] = None
|
|
180
|
+
msg_retention_days: Optional[int] = Field(default=None, ge=0)
|
|
181
|
+
log_retention_days: Optional[int] = Field(default=None, ge=0)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class HousekeepingRunResponse(BaseModel):
|
|
185
|
+
msgs_deleted: int
|
|
186
|
+
logs_deleted: int
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class BrandingResponse(BaseModel):
|
|
190
|
+
prefix: str
|
|
191
|
+
accent: str
|
|
192
|
+
bg: str
|
|
193
|
+
bg2: str
|
|
194
|
+
bg3: str
|
|
195
|
+
border: str
|
|
196
|
+
text: str
|
|
197
|
+
text2: str
|
|
198
|
+
logo_dark: str
|
|
199
|
+
logo_light: str
|
|
200
|
+
favicon: str
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BrandingRequest(BaseModel):
|
|
204
|
+
prefix: Optional[str] = None
|
|
205
|
+
accent: Optional[str] = None
|
|
206
|
+
bg: Optional[str] = None
|
|
207
|
+
bg2: Optional[str] = None
|
|
208
|
+
bg3: Optional[str] = None
|
|
209
|
+
border: Optional[str] = None
|
|
210
|
+
text: Optional[str] = None
|
|
211
|
+
text2: Optional[str] = None
|
|
212
|
+
logo_dark: Optional[str] = None
|
|
213
|
+
logo_light: Optional[str] = None
|
|
214
|
+
favicon: Optional[str] = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# FIX spec
|
|
218
|
+
|
|
219
|
+
class FieldRefResponse(BaseModel):
|
|
220
|
+
tag: int
|
|
221
|
+
name: str
|
|
222
|
+
type: str
|
|
223
|
+
required: bool
|
|
224
|
+
values: dict[str, str]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class MessageSummaryResponse(BaseModel):
|
|
228
|
+
msg_type: str
|
|
229
|
+
name: str
|
|
230
|
+
category: str
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class MessageDefResponse(BaseModel):
|
|
234
|
+
msg_type: str
|
|
235
|
+
name: str
|
|
236
|
+
category: str
|
|
237
|
+
fields: list[FieldRefResponse]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Templates
|
|
241
|
+
|
|
242
|
+
# Scenarios
|
|
243
|
+
|
|
244
|
+
class AssertionDef(BaseModel):
|
|
245
|
+
tag: str
|
|
246
|
+
op: str # eq | ne | exists | absent
|
|
247
|
+
value: str = ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ScenarioStepDef(BaseModel):
|
|
251
|
+
type: str # send | expect | delay
|
|
252
|
+
label: str
|
|
253
|
+
template_id: Optional[str] = None
|
|
254
|
+
fields: dict[str, str] = {}
|
|
255
|
+
raw: Optional[str] = None # pipe-or-SOH FIX string; when set, used instead of fields (supports repeating groups)
|
|
256
|
+
msg_type: Optional[str] = None
|
|
257
|
+
timeout_ms: int = 5000
|
|
258
|
+
assertions: list[AssertionDef] = []
|
|
259
|
+
delay_ms: int = 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ScenarioRequest(BaseModel):
|
|
263
|
+
name: str
|
|
264
|
+
session_id: str
|
|
265
|
+
steps: list[ScenarioStepDef]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ScenarioResponse(BaseModel):
|
|
269
|
+
id: str
|
|
270
|
+
name: str
|
|
271
|
+
session_id: str
|
|
272
|
+
steps: list[ScenarioStepDef]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TemplateRequest(BaseModel):
|
|
276
|
+
name: str
|
|
277
|
+
msg_type: str
|
|
278
|
+
msg_type_name: str
|
|
279
|
+
begin_string: str
|
|
280
|
+
fields: dict[str, str]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class TemplateResponse(BaseModel):
|
|
284
|
+
id: str
|
|
285
|
+
name: str
|
|
286
|
+
msg_type: str
|
|
287
|
+
msg_type_name: str
|
|
288
|
+
begin_string: str
|
|
289
|
+
fields: dict[str, str]
|
|
File without changes
|
fixture/core/__init__.py
ADDED
|
File without changes
|
fixture/core/auth.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
_SECRET_KEY: str = os.environ.get("FIXTURE_SECRET_KEY", "")
|
|
24
|
+
if not _SECRET_KEY:
|
|
25
|
+
_SECRET_KEY = secrets.token_hex(32)
|
|
26
|
+
logger.warning(
|
|
27
|
+
"FIXTURE_SECRET_KEY not set — using a random key. "
|
|
28
|
+
"Tokens will be invalidated on restart."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_secret() -> str:
|
|
33
|
+
return _SECRET_KEY
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encode(plain: str) -> bytes:
|
|
37
|
+
# bcrypt hard-limits at 72 bytes — truncate to avoid ValueError in bcrypt 5.x
|
|
38
|
+
return plain.encode("utf-8")[:72]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hash_password(plain: str) -> str:
|
|
42
|
+
return _bcrypt.hashpw(_encode(plain), _bcrypt.gensalt()).decode("utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
return _bcrypt.checkpw(_encode(plain), hashed.encode("utf-8"))
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_token(user: User) -> str:
|
|
53
|
+
import time
|
|
54
|
+
payload = {
|
|
55
|
+
"sub": user.uid,
|
|
56
|
+
"username": user.username,
|
|
57
|
+
"role": user.role,
|
|
58
|
+
"exp": int(time.time()) + _EXPIRY_SECONDS,
|
|
59
|
+
}
|
|
60
|
+
return jwt.encode(payload, _get_secret(), algorithm=_ALGORITHM)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_token(token: str) -> dict:
|
|
64
|
+
"""
|
|
65
|
+
Decode and validate a JWT. Raises jose.JWTError on invalid/expired token.
|
|
66
|
+
Returns the raw payload dict.
|
|
67
|
+
"""
|
|
68
|
+
return jwt.decode(token, _get_secret(), algorithms=[_ALGORITHM])
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from .models import SessionConfig, ConnectionType, SessionRole
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigStore:
|
|
9
|
+
"""Persists session configs to per-user JSON files under data_dir/<uid>/sessions.json."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data_dir: str):
|
|
12
|
+
self._data_dir = os.path.abspath(data_dir)
|
|
13
|
+
|
|
14
|
+
def load_all(self) -> List[SessionConfig]:
|
|
15
|
+
"""Scan data_dir/<uid>/sessions.json and return all sessions."""
|
|
16
|
+
configs = []
|
|
17
|
+
if not os.path.isdir(self._data_dir):
|
|
18
|
+
return configs
|
|
19
|
+
for uid in os.listdir(self._data_dir):
|
|
20
|
+
user_dir = os.path.join(self._data_dir, uid)
|
|
21
|
+
path = os.path.join(user_dir, "sessions.json")
|
|
22
|
+
if os.path.isfile(path):
|
|
23
|
+
for cfg in _load_file(path):
|
|
24
|
+
cfg.owner_uid = uid # authoritative from directory name
|
|
25
|
+
configs.append(cfg)
|
|
26
|
+
return configs
|
|
27
|
+
|
|
28
|
+
def save_for_user(self, uid: str, configs: List[SessionConfig]) -> None:
|
|
29
|
+
"""Atomically save one user's sessions to data_dir/<uid>/sessions.json."""
|
|
30
|
+
user_dir = os.path.join(self._data_dir, uid)
|
|
31
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
32
|
+
path = os.path.join(user_dir, "sessions.json")
|
|
33
|
+
tmp = path + ".tmp"
|
|
34
|
+
with open(tmp, "w") as f:
|
|
35
|
+
json.dump([_config_to_dict(c) for c in configs], f, indent=2)
|
|
36
|
+
os.replace(tmp, path)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_file(path: str) -> List[SessionConfig]:
|
|
40
|
+
"""Load a sessions.json file and return SessionConfig objects (owner_uid not set)."""
|
|
41
|
+
try:
|
|
42
|
+
with open(path) as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return []
|
|
46
|
+
configs = []
|
|
47
|
+
for d in data:
|
|
48
|
+
try:
|
|
49
|
+
d["connection_type"] = ConnectionType(d["connection_type"])
|
|
50
|
+
if "session_role" in d:
|
|
51
|
+
d["session_role"] = SessionRole(d["session_role"])
|
|
52
|
+
d.pop("owner_uid", None) # stripped — set by caller from directory name
|
|
53
|
+
configs.append(SessionConfig(**d))
|
|
54
|
+
except (KeyError, ValueError, TypeError):
|
|
55
|
+
pass # skip malformed entries
|
|
56
|
+
return configs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _config_to_dict(cfg: SessionConfig) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"session_id": cfg.session_id,
|
|
62
|
+
"display_name": cfg.display_name,
|
|
63
|
+
"begin_string": cfg.begin_string,
|
|
64
|
+
"sender_comp_id": cfg.sender_comp_id,
|
|
65
|
+
"target_comp_id": cfg.target_comp_id,
|
|
66
|
+
"connection_type": cfg.connection_type.value,
|
|
67
|
+
"host": cfg.host,
|
|
68
|
+
"port": cfg.port,
|
|
69
|
+
"heartbeat_interval": cfg.heartbeat_interval,
|
|
70
|
+
"reconnect_interval": cfg.reconnect_interval,
|
|
71
|
+
"data_dictionary_path": cfg.data_dictionary_path,
|
|
72
|
+
"file_store_path": cfg.file_store_path,
|
|
73
|
+
"file_log_path": cfg.file_log_path,
|
|
74
|
+
"use_file_log": cfg.use_file_log,
|
|
75
|
+
"start_time": cfg.start_time,
|
|
76
|
+
"end_time": cfg.end_time,
|
|
77
|
+
"reset_on_logon": cfg.reset_on_logon,
|
|
78
|
+
"reset_on_logout": cfg.reset_on_logout,
|
|
79
|
+
"session_role": cfg.session_role.value,
|
|
80
|
+
"auto_ack": cfg.auto_ack,
|
|
81
|
+
"auto_ack_delay_ms": cfg.auto_ack_delay_ms,
|
|
82
|
+
"auto_fill": cfg.auto_fill,
|
|
83
|
+
"auto_fill_delay_ms": cfg.auto_fill_delay_ms,
|
|
84
|
+
# owner_uid intentionally omitted — canonical from directory name
|
|
85
|
+
}
|
fixture/core/events.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum, auto
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventType(Enum):
|
|
7
|
+
SESSION_STATUS_CHANGED = auto()
|
|
8
|
+
MESSAGE_RECEIVED = auto()
|
|
9
|
+
MESSAGE_SENT = auto()
|
|
10
|
+
ENGINE_ERROR = auto()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SessionEvent:
|
|
15
|
+
event_type: EventType
|
|
16
|
+
session_id: str # FIXture UUID (from SessionConfig.session_id)
|
|
17
|
+
payload: dict = field(default_factory=dict)
|
|
18
|
+
owner_uid: str = "" # stamped by SessionManager._dispatch_event
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Type alias for subscriber callbacks
|
|
22
|
+
EventHandler = Callable[[SessionEvent], None]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from fixcore.application import Application
|
|
4
|
+
from fixcore.message.message import Message
|
|
5
|
+
from fixcore.session.session_id import SessionID
|
|
6
|
+
|
|
7
|
+
from .events import EventHandler, EventType, SessionEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FixApplication(Application):
|
|
11
|
+
"""
|
|
12
|
+
One instance per logical session. Bridges fixcore callbacks
|
|
13
|
+
into the FIXture event system.
|
|
14
|
+
|
|
15
|
+
fixcore calls these methods from the asyncio event loop — no
|
|
16
|
+
thread-crossing needed. Tasks scheduled here run on the same loop
|
|
17
|
+
as FastAPI.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session_id: str, event_handler: EventHandler):
|
|
21
|
+
self._session_id = session_id
|
|
22
|
+
self._event_handler = event_handler
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
# fixcore Application interface
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def on_create(self, session_id: SessionID) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def on_logon(self, session_id: SessionID) -> None:
|
|
32
|
+
self._emit(EventType.SESSION_STATUS_CHANGED, {"status": "LOGGED_ON"})
|
|
33
|
+
|
|
34
|
+
def on_logout(self, session_id: SessionID) -> None:
|
|
35
|
+
self._emit(EventType.SESSION_STATUS_CHANGED, {"status": "LOGGED_OUT"})
|
|
36
|
+
|
|
37
|
+
def to_admin(self, message: Message, session_id: SessionID) -> None:
|
|
38
|
+
self._emit(EventType.MESSAGE_SENT, {"raw": message.encode().decode("ascii", errors="replace"), "admin": True})
|
|
39
|
+
|
|
40
|
+
def to_app(self, message: Message, session_id: SessionID) -> None:
|
|
41
|
+
try:
|
|
42
|
+
self._emit(EventType.MESSAGE_SENT, {"raw": message.encode().decode("ascii", errors="replace"), "admin": False})
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def from_admin(self, message: Message, session_id: SessionID) -> None:
|
|
47
|
+
self._emit(EventType.MESSAGE_RECEIVED, {"raw": message.encode().decode("ascii", errors="replace"), "admin": True})
|
|
48
|
+
|
|
49
|
+
def from_app(self, message: Message, session_id: SessionID) -> None:
|
|
50
|
+
try:
|
|
51
|
+
self._emit(EventType.MESSAGE_RECEIVED, {"raw": message.encode().decode("ascii", errors="replace"), "admin": False})
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Internal
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _emit(self, event_type: EventType, payload: dict) -> None:
|
|
60
|
+
try:
|
|
61
|
+
self._event_handler(SessionEvent(
|
|
62
|
+
event_type=event_type,
|
|
63
|
+
session_id=self._session_id,
|
|
64
|
+
payload=payload,
|
|
65
|
+
))
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse raw FIX strings into structured field dicts.
|
|
3
|
+
|
|
4
|
+
Raw FIX uses SOH (\\x01) as the field delimiter.
|
|
5
|
+
Each field is "tag=value".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .fix_tags import tag_name, msg_type_name
|
|
9
|
+
|
|
10
|
+
SOH = "\x01"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_raw(raw: str) -> dict[int, str]:
|
|
14
|
+
"""
|
|
15
|
+
Parse a raw FIX string into {tag_int: value_str}.
|
|
16
|
+
Fields that cannot be parsed are silently skipped.
|
|
17
|
+
"""
|
|
18
|
+
fields: dict[int, str] = {}
|
|
19
|
+
for part in raw.split(SOH):
|
|
20
|
+
if not part:
|
|
21
|
+
continue
|
|
22
|
+
if "=" not in part:
|
|
23
|
+
continue
|
|
24
|
+
tag_str, _, value = part.partition("=")
|
|
25
|
+
try:
|
|
26
|
+
fields[int(tag_str)] = value
|
|
27
|
+
except ValueError:
|
|
28
|
+
continue
|
|
29
|
+
return fields
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_msg_type(fields: dict[int, str]) -> str:
|
|
33
|
+
"""Return raw MsgType value (tag 35), or '' if absent."""
|
|
34
|
+
return fields.get(35, "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_seq_num(fields: dict[int, str]) -> int:
|
|
38
|
+
"""Return MsgSeqNum (tag 34) as int, or 0 if absent/invalid."""
|
|
39
|
+
try:
|
|
40
|
+
return int(fields.get(34, 0))
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_fields(fields: dict[int, str], use_names: bool = True) -> list[tuple[str, str]]:
|
|
46
|
+
"""
|
|
47
|
+
Return a list of (label, value) pairs for display.
|
|
48
|
+
label is the tag name (if use_names) or the raw tag number.
|
|
49
|
+
Header tags (8, 9, 10, 34, 35, 49, 52, 56) are listed first.
|
|
50
|
+
"""
|
|
51
|
+
HEADER_TAGS = {8, 9, 34, 35, 49, 52, 56}
|
|
52
|
+
TRAILER_TAGS = {10}
|
|
53
|
+
|
|
54
|
+
header = [(t, fields[t]) for t in sorted(HEADER_TAGS) if t in fields]
|
|
55
|
+
body = [
|
|
56
|
+
(t, fields[t])
|
|
57
|
+
for t in sorted(fields)
|
|
58
|
+
if t not in HEADER_TAGS and t not in TRAILER_TAGS
|
|
59
|
+
]
|
|
60
|
+
trailer = [(t, fields[t]) for t in sorted(TRAILER_TAGS) if t in fields]
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
for tag, value in header + body + trailer:
|
|
64
|
+
label = tag_name(tag) if use_names else str(tag)
|
|
65
|
+
result.append((label, value))
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pretty_print(raw: str, use_names: bool = True) -> str:
|
|
70
|
+
"""Return a multi-line human-readable representation of a raw FIX message."""
|
|
71
|
+
fields = parse_raw(raw)
|
|
72
|
+
mt = get_msg_type(fields)
|
|
73
|
+
mt_name = msg_type_name(mt) if mt else "Unknown"
|
|
74
|
+
seq = get_seq_num(fields)
|
|
75
|
+
|
|
76
|
+
lines = [f"[{mt_name}] MsgType={mt} SeqNum={seq}"]
|
|
77
|
+
for label, value in format_fields(fields, use_names=use_names):
|
|
78
|
+
lines.append(f" {label} = {value}")
|
|
79
|
+
return "\n".join(lines)
|