fixcore-engine 0.2.0__tar.gz → 0.3.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.0/fixcore_engine.egg-info → fixcore_engine-0.3.0}/PKG-INFO +1 -1
  2. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/__init__.py +4 -2
  3. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/data_dictionary.py +50 -5
  4. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/exceptions.py +17 -1
  5. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/message.py +45 -8
  6. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/session/session.py +296 -101
  7. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/session/session_settings.py +10 -0
  8. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/session/state.py +3 -0
  9. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/store/file_store.py +17 -5
  10. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0/fixcore_engine.egg-info}/PKG-INFO +1 -1
  11. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/pyproject.toml +1 -1
  12. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_file_store.py +30 -0
  13. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_message.py +70 -0
  14. fixcore_engine-0.3.0/tests/test_session.py +966 -0
  15. fixcore_engine-0.2.0/tests/test_session.py +0 -522
  16. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/LICENSE +0 -0
  17. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/README.md +0 -0
  18. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/__init__.py +0 -0
  19. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/application.py +0 -0
  20. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/gui.py +0 -0
  21. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/gui_ui/app.js +0 -0
  22. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/gui_ui/fixcore_logo.svg +0 -0
  23. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/gui_ui/index.html +0 -0
  24. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/gui_ui/style.css +0 -0
  25. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/log/__init__.py +0 -0
  26. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/log/base.py +0 -0
  27. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/log/factory.py +0 -0
  28. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/log/file_log.py +0 -0
  29. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/log/screen.py +0 -0
  30. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/cracker.py +0 -0
  31. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/message/field.py +0 -0
  32. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/session/__init__.py +0 -0
  33. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/session/session_id.py +0 -0
  34. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/store/__init__.py +0 -0
  35. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/store/base.py +0 -0
  36. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/store/factory.py +0 -0
  37. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/store/memory.py +0 -0
  38. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/transport/__init__.py +0 -0
  39. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/transport/acceptor.py +0 -0
  40. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/transport/framer.py +0 -0
  41. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore/transport/initiator.py +0 -0
  42. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/SOURCES.txt +0 -0
  43. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/dependency_links.txt +0 -0
  44. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/entry_points.txt +0 -0
  45. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/requires.txt +0 -0
  46. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/top_level.txt +0 -0
  47. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/setup.cfg +0 -0
  48. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_cracker.py +0 -0
  49. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_data_dictionary.py +0 -0
  50. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_file_log.py +0 -0
  51. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_framer.py +0 -0
  52. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_integration.py +0 -0
  53. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_session_id.py +0 -0
  54. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_store.py +0 -0
  55. {fixcore_engine-0.2.0 → fixcore_engine-0.3.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixcore-engine
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Pure Python FIX protocol engine
5
5
  Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
6
  License: MIT License
@@ -1,7 +1,9 @@
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, FieldDef, GroupDef, MessageDef, load_data_dictionary,
6
+ )
5
7
  from fixcore.message.exceptions import (
6
8
  FieldNotFound, InvalidMessage, UnsupportedMessageType, UnsupportedVersion,
7
9
  )
@@ -10,7 +12,7 @@ from fixcore.message.message import Header, Message, Trailer
10
12
 
11
13
  __all__ = [
12
14
  "MessageCracker",
13
- "DataDictionary", "FieldDef", "GroupDef", "MessageDef",
15
+ "DataDictionary", "FieldDef", "GroupDef", "MessageDef", "load_data_dictionary",
14
16
  "FieldNotFound", "InvalidMessage", "UnsupportedMessageType", "UnsupportedVersion",
15
17
  "Field", "FieldMap", "Group",
16
18
  "Header", "Message", "Trailer",
@@ -4,10 +4,15 @@ 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 lru_cache
7
8
  from pathlib import Path
8
9
 
9
10
  from fixcore.message.exceptions import InvalidMessage
10
11
 
12
+ # SessionRejectReason codes (FIX session-level)
13
+ REJECT_REQUIRED_TAG_MISSING = 1
14
+ REJECT_INVALID_MSGTYPE = 11
15
+
11
16
  # Imported here to avoid circular import at module level; also used as constants
12
17
  TAG_BODY_LENGTH = 9
13
18
  TAG_CHECKSUM = 10
@@ -242,11 +247,19 @@ class DataDictionary:
242
247
 
243
248
  msg_type = message.msg_type
244
249
  if not msg_type:
245
- raise InvalidMessage("Missing MsgType (tag 35)")
250
+ raise InvalidMessage(
251
+ "Missing MsgType (tag 35)",
252
+ reason=REJECT_REQUIRED_TAG_MISSING,
253
+ ref_tag=35,
254
+ )
246
255
 
247
256
  msg_def = self._messages.get(msg_type)
248
257
  if msg_def is None:
249
- raise InvalidMessage(f"Unknown MsgType: {msg_type!r}")
258
+ raise InvalidMessage(
259
+ f"Unknown MsgType: {msg_type!r}",
260
+ reason=REJECT_INVALID_MSGTYPE,
261
+ ref_tag=35,
262
+ )
250
263
 
251
264
  # BodyLength and CheckSum are computed by the engine on encode — skip them
252
265
  COMPUTED_TAGS = {TAG_BODY_LENGTH, TAG_CHECKSUM}
@@ -257,14 +270,20 @@ class DataDictionary:
257
270
  continue
258
271
  if required and not message.header.has(tag):
259
272
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
260
- raise InvalidMessage(f"Missing required header field: {name} ({tag})")
273
+ raise InvalidMessage(
274
+ f"Missing required header field: {name} ({tag})",
275
+ reason=REJECT_REQUIRED_TAG_MISSING,
276
+ ref_tag=tag,
277
+ )
261
278
 
262
279
  # Required body fields
263
280
  for tag in msg_def.required:
264
281
  if not message.has_field(tag):
265
282
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
266
283
  raise InvalidMessage(
267
- f"Missing required field: {name} ({tag}) in {msg_def.name}"
284
+ f"Missing required field: {name} ({tag}) in {msg_def.name}",
285
+ reason=REJECT_REQUIRED_TAG_MISSING,
286
+ ref_tag=tag,
268
287
  )
269
288
 
270
289
  # Required trailer fields
@@ -273,7 +292,11 @@ class DataDictionary:
273
292
  continue
274
293
  if required and not message.trailer.has(tag):
275
294
  name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
276
- raise InvalidMessage(f"Missing required trailer field: {name} ({tag})")
295
+ raise InvalidMessage(
296
+ f"Missing required trailer field: {name} ({tag})",
297
+ reason=REJECT_REQUIRED_TAG_MISSING,
298
+ ref_tag=tag,
299
+ )
277
300
 
278
301
  def validate_field_value(self, tag: int, value: str) -> bool:
279
302
  """Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
@@ -283,6 +306,28 @@ class DataDictionary:
283
306
  return value in fd.values
284
307
 
285
308
 
309
+ # ---------------------------------------------------------------------------
310
+ # Cached loading
311
+ # ---------------------------------------------------------------------------
312
+
313
+ @lru_cache(maxsize=None)
314
+ def _load_cached(resolved_path: str) -> DataDictionary:
315
+ return DataDictionary.from_xml(resolved_path)
316
+
317
+
318
+ def load_data_dictionary(path: str | Path) -> DataDictionary:
319
+ """Load (and cache) a DataDictionary from *path*.
320
+
321
+ Multiple sessions sharing the same spec file reuse a single parsed
322
+ instance. Raises :exc:`FileNotFoundError` with a clear message if the file
323
+ does not exist.
324
+ """
325
+ resolved = Path(path).expanduser()
326
+ if not resolved.is_file():
327
+ raise FileNotFoundError(f"DataDictionary spec not found: {path}")
328
+ return _load_cached(str(resolved.resolve()))
329
+
330
+
286
331
  # ---------------------------------------------------------------------------
287
332
  # Helpers
288
333
  # ---------------------------------------------------------------------------
@@ -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):
@@ -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}"