fixcore-engine 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {fixcore_engine-0.2.1/fixcore_engine.egg-info → fixcore_engine-0.4.0}/PKG-INFO +1 -1
  2. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/file_log.py +2 -2
  3. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/screen.py +2 -2
  4. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/__init__.py +12 -3
  5. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/cracker.py +5 -2
  6. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/data_dictionary.py +84 -12
  7. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/exceptions.py +17 -1
  8. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/field.py +4 -4
  9. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/message.py +46 -9
  10. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session.py +311 -107
  11. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session_settings.py +24 -6
  12. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/state.py +3 -0
  13. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/file_store.py +17 -5
  14. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/acceptor.py +1 -1
  15. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/initiator.py +4 -4
  16. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0/fixcore_engine.egg-info}/PKG-INFO +1 -1
  17. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/pyproject.toml +1 -1
  18. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_cracker.py +10 -3
  19. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_data_dictionary.py +25 -1
  20. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_file_log.py +1 -2
  21. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_file_store.py +30 -2
  22. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_framer.py +7 -2
  23. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_integration.py +5 -6
  24. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_message.py +78 -1
  25. fixcore_engine-0.4.0/tests/test_session.py +990 -0
  26. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_transport.py +2 -5
  27. fixcore_engine-0.2.1/tests/test_session.py +0 -522
  28. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/LICENSE +0 -0
  29. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/README.md +0 -0
  30. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/__init__.py +0 -0
  31. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/application.py +0 -0
  32. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui.py +0 -0
  33. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/app.js +0 -0
  34. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/fixcore_logo.svg +0 -0
  35. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/index.html +0 -0
  36. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/style.css +0 -0
  37. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/__init__.py +0 -0
  38. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/base.py +0 -0
  39. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/factory.py +0 -0
  40. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/__init__.py +0 -0
  41. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session_id.py +0 -0
  42. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/__init__.py +0 -0
  43. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/base.py +0 -0
  44. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/factory.py +0 -0
  45. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/memory.py +0 -0
  46. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/__init__.py +0 -0
  47. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/framer.py +0 -0
  48. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/SOURCES.txt +0 -0
  49. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/dependency_links.txt +0 -0
  50. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/entry_points.txt +0 -0
  51. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/requires.txt +0 -0
  52. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/top_level.txt +0 -0
  53. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/setup.cfg +0 -0
  54. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_session_id.py +0 -0
  55. {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixcore-engine
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Pure Python FIX protocol engine
5
5
  Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
6
  License: MIT License
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from pathlib import Path
7
7
 
8
8
  from fixcore.log.base import Log, LogFactory
@@ -20,7 +20,7 @@ def _session_filename(session_id: SessionID) -> str:
20
20
 
21
21
 
22
22
  def _now() -> str:
23
- return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
23
+ return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
24
24
 
25
25
 
26
26
  class FileLog(Log):
@@ -3,14 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
- from datetime import datetime, timezone
6
+ from datetime import UTC, datetime
7
7
 
8
8
  from fixcore.log.base import Log, LogFactory
9
9
  from fixcore.session.session_id import SessionID
10
10
 
11
11
 
12
12
  def _now() -> str:
13
- return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
13
+ return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
14
14
 
15
15
 
16
16
  class ScreenLog(Log):
@@ -1,16 +1,25 @@
1
1
  """FIX message layer — encoding, decoding, and field definitions."""
2
2
 
3
3
  from fixcore.message.cracker import MessageCracker
4
- from fixcore.message.data_dictionary import DataDictionary, FieldDef, GroupDef, MessageDef
4
+ from fixcore.message.data_dictionary import (
5
+ DataDictionary,
6
+ FieldDef,
7
+ GroupDef,
8
+ MessageDef,
9
+ load_data_dictionary,
10
+ )
5
11
  from fixcore.message.exceptions import (
6
- FieldNotFound, InvalidMessage, UnsupportedMessageType, UnsupportedVersion,
12
+ FieldNotFound,
13
+ InvalidMessage,
14
+ UnsupportedMessageType,
15
+ UnsupportedVersion,
7
16
  )
8
17
  from fixcore.message.field import Field, FieldMap, Group
9
18
  from fixcore.message.message import Header, Message, Trailer
10
19
 
11
20
  __all__ = [
12
21
  "MessageCracker",
13
- "DataDictionary", "FieldDef", "GroupDef", "MessageDef",
22
+ "DataDictionary", "FieldDef", "GroupDef", "MessageDef", "load_data_dictionary",
14
23
  "FieldNotFound", "InvalidMessage", "UnsupportedMessageType", "UnsupportedVersion",
15
24
  "Field", "FieldMap", "Group",
16
25
  "Header", "Message", "Trailer",
@@ -25,11 +25,12 @@ level::
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ from collections.abc import Callable
29
+
28
30
  from fixcore.message.exceptions import UnsupportedMessageType
29
31
  from fixcore.message.message import Message
30
32
  from fixcore.session.session_id import SessionID
31
33
 
32
-
33
34
  # ---------------------------------------------------------------------------
34
35
  # MsgType → handler method name registry
35
36
  # ---------------------------------------------------------------------------
@@ -230,7 +231,9 @@ class MessageCracker:
230
231
  # already have an explicit implementation on the class.
231
232
  # ---------------------------------------------------------------------------
232
233
 
233
- def _make_default_handler(name: str): # noqa: ANN202
234
+ def _make_default_handler(
235
+ name: str,
236
+ ) -> Callable[[MessageCracker, Message, SessionID], None]:
234
237
  def handler(self: MessageCracker, message: Message, session_id: SessionID) -> None:
235
238
  self.on_message(message, session_id)
236
239
  handler.__name__ = name
@@ -4,11 +4,22 @@ from __future__ import annotations
4
4
 
5
5
  import xml.etree.ElementTree as ET
6
6
  from dataclasses import dataclass, field
7
+ from functools import cache
7
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING
8
10
 
9
11
  from fixcore.message.exceptions import InvalidMessage
10
12
 
13
+ if TYPE_CHECKING:
14
+ from fixcore.message.message import Message
15
+
16
+ # SessionRejectReason codes (FIX session-level)
17
+ REJECT_REQUIRED_TAG_MISSING = 1
18
+ REJECT_VALUE_OUT_OF_RANGE = 5
19
+ REJECT_INVALID_MSGTYPE = 11
20
+
11
21
  # Imported here to avoid circular import at module level; also used as constants
22
+ TAG_MSG_TYPE = 35
12
23
  TAG_BODY_LENGTH = 9
13
24
  TAG_CHECKSUM = 10
14
25
 
@@ -32,7 +43,7 @@ class GroupDef:
32
43
  number_tag: int # NoXxx counter tag (e.g. 78 NoAllocs)
33
44
  delimiter: int # first expected tag inside each instance
34
45
  members: list[int] # ordered member tags
35
- nested_groups: dict[int, "GroupDef"] = field(default_factory=dict)
46
+ nested_groups: dict[int, GroupDef] = field(default_factory=dict)
36
47
 
37
48
 
38
49
  @dataclass
@@ -76,7 +87,7 @@ class DataDictionary:
76
87
  # ------------------------------------------------------------------
77
88
 
78
89
  @classmethod
79
- def from_xml(cls, path: str | Path) -> "DataDictionary":
90
+ def from_xml(cls, path: str | Path) -> DataDictionary:
80
91
  """Load a DataDictionary from a QuickFIX XML spec file."""
81
92
  tree = ET.parse(str(path))
82
93
  root = tree.getroot()
@@ -89,7 +100,7 @@ class DataDictionary:
89
100
  return dd
90
101
 
91
102
  @classmethod
92
- def from_string(cls, xml_text: str) -> "DataDictionary":
103
+ def from_string(cls, xml_text: str) -> DataDictionary:
93
104
  """Load a DataDictionary from an XML string (useful in tests)."""
94
105
  root = ET.fromstring(xml_text)
95
106
  dd = cls()
@@ -147,7 +158,9 @@ class DataDictionary:
147
158
  name = msg_el.get("name", "")
148
159
  cat = msg_el.get("msgcat", "app").lower()
149
160
  msg_def = MessageDef(name=name, msg_type=msg_type, msg_cat=cat)
150
- self._parse_fields_and_groups(msg_el, msg_def.required, msg_def.optional, msg_def.groups)
161
+ self._parse_fields_and_groups(
162
+ msg_el, msg_def.required, msg_def.optional, msg_def.groups
163
+ )
151
164
  self._messages[msg_type] = msg_def
152
165
 
153
166
  def _parse_fields_and_groups(
@@ -196,7 +209,9 @@ class DataDictionary:
196
209
  delimiter = tag
197
210
  members.append(tag)
198
211
  nested[tag] = self._parse_group(tag, child)
199
- return GroupDef(number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested)
212
+ return GroupDef(
213
+ number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested
214
+ )
200
215
 
201
216
  def _resolve_tag(self, name: str) -> int | None:
202
217
  return self._fields_by_name.get(name)
@@ -227,7 +242,7 @@ class DataDictionary:
227
242
  def is_trailer_field(self, tag: int) -> bool:
228
243
  return tag in self._trailer
229
244
 
230
- def validate(self, message: "Message") -> None: # type: ignore[name-defined]
245
+ def validate(self, message: Message, *, check_field_values: bool = False) -> None:
231
246
  """Validate *message* against this DataDictionary.
232
247
 
233
248
  Checks:
@@ -235,18 +250,27 @@ class DataDictionary:
235
250
  - All required header fields are present
236
251
  - All required body fields are present
237
252
  - All required trailer fields are present
253
+ - If *check_field_values* is True, every enum-bearing header/body field
254
+ carries a value defined in the dictionary (out-of-range → Reject)
238
255
 
239
256
  Raises :exc:`InvalidMessage` on the first violation found.
240
257
  """
241
- from fixcore.message.message import Message # local to avoid circular
242
258
 
243
259
  msg_type = message.msg_type
244
260
  if not msg_type:
245
- raise InvalidMessage("Missing MsgType (tag 35)")
261
+ raise InvalidMessage(
262
+ "Missing MsgType (tag 35)",
263
+ reason=REJECT_REQUIRED_TAG_MISSING,
264
+ ref_tag=35,
265
+ )
246
266
 
247
267
  msg_def = self._messages.get(msg_type)
248
268
  if msg_def is None:
249
- raise InvalidMessage(f"Unknown MsgType: {msg_type!r}")
269
+ raise InvalidMessage(
270
+ f"Unknown MsgType: {msg_type!r}",
271
+ reason=REJECT_INVALID_MSGTYPE,
272
+ ref_tag=35,
273
+ )
250
274
 
251
275
  # BodyLength and CheckSum are computed by the engine on encode — skip them
252
276
  COMPUTED_TAGS = {TAG_BODY_LENGTH, TAG_CHECKSUM}
@@ -257,14 +281,20 @@ class DataDictionary:
257
281
  continue
258
282
  if required and not message.header.has(tag):
259
283
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
260
- raise InvalidMessage(f"Missing required header field: {name} ({tag})")
284
+ raise InvalidMessage(
285
+ f"Missing required header field: {name} ({tag})",
286
+ reason=REJECT_REQUIRED_TAG_MISSING,
287
+ ref_tag=tag,
288
+ )
261
289
 
262
290
  # Required body fields
263
291
  for tag in msg_def.required:
264
292
  if not message.has_field(tag):
265
293
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
266
294
  raise InvalidMessage(
267
- f"Missing required field: {name} ({tag}) in {msg_def.name}"
295
+ f"Missing required field: {name} ({tag}) in {msg_def.name}",
296
+ reason=REJECT_REQUIRED_TAG_MISSING,
297
+ ref_tag=tag,
268
298
  )
269
299
 
270
300
  # Required trailer fields
@@ -273,7 +303,27 @@ class DataDictionary:
273
303
  continue
274
304
  if required and not message.trailer.has(tag):
275
305
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
276
- raise InvalidMessage(f"Missing required trailer field: {name} ({tag})")
306
+ raise InvalidMessage(
307
+ f"Missing required trailer field: {name} ({tag})",
308
+ reason=REJECT_REQUIRED_TAG_MISSING,
309
+ ref_tag=tag,
310
+ )
311
+
312
+ # Enum (out-of-range) field-value checks — header + body flat fields.
313
+ # MsgType is skipped (already validated against the message set above);
314
+ # tags without a defined enum no-op in validate_field_value.
315
+ if check_field_values:
316
+ fields = list(message.header.items()) + list(message.body.items())
317
+ for tag, value in fields:
318
+ if tag in COMPUTED_TAGS or tag == TAG_MSG_TYPE:
319
+ continue
320
+ if not self.validate_field_value(tag, value):
321
+ name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
322
+ raise InvalidMessage(
323
+ f"Value {value!r} out of range for {name} ({tag})",
324
+ reason=REJECT_VALUE_OUT_OF_RANGE,
325
+ ref_tag=tag,
326
+ )
277
327
 
278
328
  def validate_field_value(self, tag: int, value: str) -> bool:
279
329
  """Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
@@ -283,6 +333,28 @@ class DataDictionary:
283
333
  return value in fd.values
284
334
 
285
335
 
336
+ # ---------------------------------------------------------------------------
337
+ # Cached loading
338
+ # ---------------------------------------------------------------------------
339
+
340
+ @cache
341
+ def _load_cached(resolved_path: str) -> DataDictionary:
342
+ return DataDictionary.from_xml(resolved_path)
343
+
344
+
345
+ def load_data_dictionary(path: str | Path) -> DataDictionary:
346
+ """Load (and cache) a DataDictionary from *path*.
347
+
348
+ Multiple sessions sharing the same spec file reuse a single parsed
349
+ instance. Raises :exc:`FileNotFoundError` with a clear message if the file
350
+ does not exist.
351
+ """
352
+ resolved = Path(path).expanduser()
353
+ if not resolved.is_file():
354
+ raise FileNotFoundError(f"DataDictionary spec not found: {path}")
355
+ return _load_cached(str(resolved.resolve()))
356
+
357
+
286
358
  # ---------------------------------------------------------------------------
287
359
  # Helpers
288
360
  # ---------------------------------------------------------------------------
@@ -1,8 +1,24 @@
1
1
  """FIX message exceptions."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
 
4
6
  class InvalidMessage(Exception):
5
- """Raised when a FIX message fails DataDictionary validation."""
7
+ """Raised when a FIX message fails DataDictionary validation.
8
+
9
+ Optionally carries the FIX SessionRejectReason (*reason*) and the offending
10
+ tag (*ref_tag*) so the session layer can emit a spec-compliant Reject.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ reason: int | None = None,
17
+ ref_tag: int | None = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.reason = reason
21
+ self.ref_tag = ref_tag
6
22
 
7
23
 
8
24
  class UnsupportedVersion(Exception):
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections import OrderedDict
6
- from typing import Iterator
6
+ from collections.abc import Iterator
7
7
 
8
8
 
9
9
  class Field:
@@ -106,7 +106,7 @@ class Group:
106
106
  self._fields: dict[int, str] = {}
107
107
  # Insertion order for both simple fields and nested-group count tags
108
108
  self._order: list[int] = []
109
- self._groups: dict[int, list["Group"]] = {}
109
+ self._groups: dict[int, list[Group]] = {}
110
110
 
111
111
  # ------------------------------------------------------------------
112
112
  # Simple field access (mirrors Message body API)
@@ -131,7 +131,7 @@ class Group:
131
131
  # Nested groups
132
132
  # ------------------------------------------------------------------
133
133
 
134
- def add_group(self, count_tag: int, instance: "Group") -> None:
134
+ def add_group(self, count_tag: int, instance: Group) -> None:
135
135
  """Append a nested group instance and update the count field."""
136
136
  if count_tag not in self._groups:
137
137
  self._order.append(count_tag)
@@ -139,7 +139,7 @@ class Group:
139
139
  self._groups[count_tag].append(instance)
140
140
  self._fields[count_tag] = str(len(self._groups[count_tag]))
141
141
 
142
- def get_groups(self, count_tag: int) -> "list[Group]":
142
+ def get_groups(self, count_tag: int) -> list[Group]:
143
143
  return self._groups.get(count_tag, [])
144
144
 
145
145
  def __repr__(self) -> str:
@@ -213,7 +213,7 @@ class Message:
213
213
  cls,
214
214
  raw: bytes,
215
215
  data_dictionary: DataDictionary | None = None,
216
- ) -> "Message":
216
+ ) -> Message:
217
217
  """Parse *raw* SOH-delimited bytes into a Message.
218
218
 
219
219
  Raises ValueError on malformed input or checksum/body-length mismatch.
@@ -225,6 +225,7 @@ class Message:
225
225
  fields = _parse_fields(raw)
226
226
  if not fields:
227
227
  raise ValueError("Empty message")
228
+ _validate_field_order(fields)
228
229
 
229
230
  msg = cls()
230
231
 
@@ -264,8 +265,20 @@ class Message:
264
265
  return self.header.get_or(TAG_MSG_TYPE)
265
266
 
266
267
  def __repr__(self) -> str:
267
- raw = self.encode()
268
- return raw.replace(SOH, b"|").decode("latin-1")
268
+ try:
269
+ raw = self.encode()
270
+ return raw.replace(SOH, b"|").decode("latin-1")
271
+ except Exception:
272
+ # Partially-built message (e.g. no BeginString): show the fields
273
+ # we have without the computed BodyLength / CheckSum.
274
+ parts = [f"{t}={v}" for t, v in self.header.items()]
275
+ for tag in self._body_order:
276
+ if tag in self._body_groups:
277
+ parts.append(f"{tag}={len(self._body_groups[tag])}")
278
+ else:
279
+ parts.append(f"{tag}={self.body[tag]}")
280
+ parts += [f"{t}={v}" for t, v in self.trailer.items()]
281
+ return f"Message({'|'.join(parts)})"
269
282
 
270
283
 
271
284
  # ---------------------------------------------------------------------------
@@ -366,6 +379,22 @@ def _checksum(data: bytes) -> int:
366
379
  return sum(data) % 256
367
380
 
368
381
 
382
+ def _validate_field_order(fields: list[tuple[int, str]]) -> None:
383
+ """Enforce the FIX positional invariants for the standard header/trailer.
384
+
385
+ Tag 8 (BeginString) must be first, 9 (BodyLength) second, 35 (MsgType)
386
+ third, and 10 (CheckSum) last.
387
+ """
388
+ if fields[0][0] != TAG_BEGIN_STRING:
389
+ raise ValueError("First field must be BeginString (tag 8)")
390
+ if len(fields) < 2 or fields[1][0] != TAG_BODY_LENGTH:
391
+ raise ValueError("Second field must be BodyLength (tag 9)")
392
+ if len(fields) < 3 or fields[2][0] != TAG_MSG_TYPE:
393
+ raise ValueError("Third field must be MsgType (tag 35)")
394
+ if fields[-1][0] != TAG_CHECKSUM:
395
+ raise ValueError("Last field must be CheckSum (tag 10)")
396
+
397
+
369
398
  def _parse_fields(raw: bytes) -> list[tuple[int, str]]:
370
399
  fields: list[tuple[int, str]] = []
371
400
  for part in raw.split(SOH):
@@ -381,22 +410,30 @@ def _parse_fields(raw: bytes) -> list[tuple[int, str]]:
381
410
 
382
411
 
383
412
  def _validate(raw: bytes, msg: Message) -> None:
384
- # Validate checksum
413
+ # Locate the CheckSum field by its trailing SOH. ``checksum_idx`` points at
414
+ # the SOH immediately before "10="; everything up to and including it is
415
+ # covered by the checksum.
385
416
  checksum_idx = raw.rfind(b"\x0110=")
386
417
  if checksum_idx == -1:
387
418
  raise ValueError("Missing CheckSum field (tag 10)")
419
+
420
+ # Validate checksum
388
421
  computed = _checksum(raw[: checksum_idx + 1]) # include the leading SOH
389
422
  declared = int(msg.trailer.get(TAG_CHECKSUM))
390
423
  if computed != declared:
391
424
  raise ValueError(f"CheckSum mismatch: computed {computed:03d}, got {declared:03d}")
392
425
 
393
- # Validate body length
426
+ # Validate body length. BodyLength counts the bytes from the start of tag 35
427
+ # up to (and including) the SOH before tag 10. The start is anchored on the
428
+ # SOH positions of the first two fields (8=… then 9=…) rather than a literal
429
+ # search for "35=", which could otherwise match inside a field value.
394
430
  if msg.header.has(TAG_BODY_LENGTH):
395
431
  declared_len = int(msg.header.get(TAG_BODY_LENGTH))
396
- # body length = bytes from tag 35 up to (not including) tag 10 field
397
- start = raw.find(b"35=")
398
- end = raw.rfind(b"\x0110=") + 1 # include the SOH before tag 10
399
- actual_len = end - start
432
+ begin_string_soh = raw.find(b"\x01")
433
+ body_length_soh = raw.find(b"\x01", begin_string_soh + 1)
434
+ body_start = body_length_soh + 1 # first byte after "9=<len>\x01" (tag 35)
435
+ end = checksum_idx + 1 # include the SOH before tag 10
436
+ actual_len = end - body_start
400
437
  if actual_len != declared_len:
401
438
  raise ValueError(
402
439
  f"BodyLength mismatch: declared {declared_len}, actual {actual_len}"