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/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
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)