fixcore-engine 0.2.1__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.
- {fixcore_engine-0.2.1/fixcore_engine.egg-info → fixcore_engine-0.3.0}/PKG-INFO +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/__init__.py +4 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/data_dictionary.py +50 -5
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/exceptions.py +17 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/message.py +45 -8
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/session/session.py +296 -101
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/session/session_settings.py +10 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/session/state.py +3 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/store/file_store.py +17 -5
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0/fixcore_engine.egg-info}/PKG-INFO +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/pyproject.toml +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_file_store.py +30 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_message.py +70 -0
- fixcore_engine-0.3.0/tests/test_session.py +966 -0
- fixcore_engine-0.2.1/tests/test_session.py +0 -522
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/LICENSE +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/README.md +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/application.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/gui.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/gui_ui/app.js +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/gui_ui/fixcore_logo.svg +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/gui_ui/index.html +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/gui_ui/style.css +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/log/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/log/base.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/log/factory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/log/file_log.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/log/screen.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/cracker.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/message/field.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/session/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/session/session_id.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/store/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/store/base.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/store/factory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/store/memory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/transport/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/transport/acceptor.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/transport/framer.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore/transport/initiator.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/SOURCES.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/dependency_links.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/entry_points.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/requires.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/fixcore_engine.egg-info/top_level.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/setup.cfg +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_cracker.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_data_dictionary.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_file_log.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_framer.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_integration.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_session_id.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_store.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.3.0}/tests/test_transport.py +0 -0
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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}"
|