fixcore-engine 0.3.0__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.
- {fixcore_engine-0.3.0/fixcore_engine.egg-info → fixcore_engine-0.4.0}/PKG-INFO +1 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/log/file_log.py +2 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/log/screen.py +2 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/__init__.py +9 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/cracker.py +5 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/data_dictionary.py +36 -9
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/field.py +4 -4
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/message.py +1 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/session/session.py +16 -7
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/session/session_settings.py +16 -8
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/transport/acceptor.py +1 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/transport/initiator.py +4 -4
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0/fixcore_engine.egg-info}/PKG-INFO +1 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/pyproject.toml +1 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_cracker.py +10 -3
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_data_dictionary.py +25 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_file_log.py +1 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_file_store.py +0 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_framer.py +7 -2
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_integration.py +5 -6
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_message.py +8 -1
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_session.py +27 -3
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_transport.py +2 -5
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/LICENSE +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/README.md +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/__init__.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/application.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/gui.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/gui_ui/app.js +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/gui_ui/fixcore_logo.svg +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/gui_ui/index.html +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/gui_ui/style.css +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/log/__init__.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/log/base.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/log/factory.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/message/exceptions.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/session/__init__.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/session/session_id.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/session/state.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/store/__init__.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/store/base.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/store/factory.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/store/file_store.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/store/memory.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/transport/__init__.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore/transport/framer.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/SOURCES.txt +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/dependency_links.txt +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/entry_points.txt +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/requires.txt +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/top_level.txt +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/setup.cfg +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_session_id.py +0 -0
- {fixcore_engine-0.3.0 → fixcore_engine-0.4.0}/tests/test_store.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
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=
|
|
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
|
|
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=
|
|
13
|
+
return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class ScreenLog(Log):
|
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
from fixcore.message.cracker import MessageCracker
|
|
4
4
|
from fixcore.message.data_dictionary import (
|
|
5
|
-
DataDictionary,
|
|
5
|
+
DataDictionary,
|
|
6
|
+
FieldDef,
|
|
7
|
+
GroupDef,
|
|
8
|
+
MessageDef,
|
|
9
|
+
load_data_dictionary,
|
|
6
10
|
)
|
|
7
11
|
from fixcore.message.exceptions import (
|
|
8
|
-
FieldNotFound,
|
|
12
|
+
FieldNotFound,
|
|
13
|
+
InvalidMessage,
|
|
14
|
+
UnsupportedMessageType,
|
|
15
|
+
UnsupportedVersion,
|
|
9
16
|
)
|
|
10
17
|
from fixcore.message.field import Field, FieldMap, Group
|
|
11
18
|
from fixcore.message.message import 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(
|
|
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,16 +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
|
|
7
|
+
from functools import cache
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
10
11
|
from fixcore.message.exceptions import InvalidMessage
|
|
11
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fixcore.message.message import Message
|
|
15
|
+
|
|
12
16
|
# SessionRejectReason codes (FIX session-level)
|
|
13
17
|
REJECT_REQUIRED_TAG_MISSING = 1
|
|
18
|
+
REJECT_VALUE_OUT_OF_RANGE = 5
|
|
14
19
|
REJECT_INVALID_MSGTYPE = 11
|
|
15
20
|
|
|
16
21
|
# Imported here to avoid circular import at module level; also used as constants
|
|
22
|
+
TAG_MSG_TYPE = 35
|
|
17
23
|
TAG_BODY_LENGTH = 9
|
|
18
24
|
TAG_CHECKSUM = 10
|
|
19
25
|
|
|
@@ -37,7 +43,7 @@ class GroupDef:
|
|
|
37
43
|
number_tag: int # NoXxx counter tag (e.g. 78 NoAllocs)
|
|
38
44
|
delimiter: int # first expected tag inside each instance
|
|
39
45
|
members: list[int] # ordered member tags
|
|
40
|
-
nested_groups: dict[int,
|
|
46
|
+
nested_groups: dict[int, GroupDef] = field(default_factory=dict)
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
@dataclass
|
|
@@ -81,7 +87,7 @@ class DataDictionary:
|
|
|
81
87
|
# ------------------------------------------------------------------
|
|
82
88
|
|
|
83
89
|
@classmethod
|
|
84
|
-
def from_xml(cls, path: str | Path) ->
|
|
90
|
+
def from_xml(cls, path: str | Path) -> DataDictionary:
|
|
85
91
|
"""Load a DataDictionary from a QuickFIX XML spec file."""
|
|
86
92
|
tree = ET.parse(str(path))
|
|
87
93
|
root = tree.getroot()
|
|
@@ -94,7 +100,7 @@ class DataDictionary:
|
|
|
94
100
|
return dd
|
|
95
101
|
|
|
96
102
|
@classmethod
|
|
97
|
-
def from_string(cls, xml_text: str) ->
|
|
103
|
+
def from_string(cls, xml_text: str) -> DataDictionary:
|
|
98
104
|
"""Load a DataDictionary from an XML string (useful in tests)."""
|
|
99
105
|
root = ET.fromstring(xml_text)
|
|
100
106
|
dd = cls()
|
|
@@ -152,7 +158,9 @@ class DataDictionary:
|
|
|
152
158
|
name = msg_el.get("name", "")
|
|
153
159
|
cat = msg_el.get("msgcat", "app").lower()
|
|
154
160
|
msg_def = MessageDef(name=name, msg_type=msg_type, msg_cat=cat)
|
|
155
|
-
self._parse_fields_and_groups(
|
|
161
|
+
self._parse_fields_and_groups(
|
|
162
|
+
msg_el, msg_def.required, msg_def.optional, msg_def.groups
|
|
163
|
+
)
|
|
156
164
|
self._messages[msg_type] = msg_def
|
|
157
165
|
|
|
158
166
|
def _parse_fields_and_groups(
|
|
@@ -201,7 +209,9 @@ class DataDictionary:
|
|
|
201
209
|
delimiter = tag
|
|
202
210
|
members.append(tag)
|
|
203
211
|
nested[tag] = self._parse_group(tag, child)
|
|
204
|
-
return GroupDef(
|
|
212
|
+
return GroupDef(
|
|
213
|
+
number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested
|
|
214
|
+
)
|
|
205
215
|
|
|
206
216
|
def _resolve_tag(self, name: str) -> int | None:
|
|
207
217
|
return self._fields_by_name.get(name)
|
|
@@ -232,7 +242,7 @@ class DataDictionary:
|
|
|
232
242
|
def is_trailer_field(self, tag: int) -> bool:
|
|
233
243
|
return tag in self._trailer
|
|
234
244
|
|
|
235
|
-
def validate(self, message:
|
|
245
|
+
def validate(self, message: Message, *, check_field_values: bool = False) -> None:
|
|
236
246
|
"""Validate *message* against this DataDictionary.
|
|
237
247
|
|
|
238
248
|
Checks:
|
|
@@ -240,10 +250,11 @@ class DataDictionary:
|
|
|
240
250
|
- All required header fields are present
|
|
241
251
|
- All required body fields are present
|
|
242
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)
|
|
243
255
|
|
|
244
256
|
Raises :exc:`InvalidMessage` on the first violation found.
|
|
245
257
|
"""
|
|
246
|
-
from fixcore.message.message import Message # local to avoid circular
|
|
247
258
|
|
|
248
259
|
msg_type = message.msg_type
|
|
249
260
|
if not msg_type:
|
|
@@ -298,6 +309,22 @@ class DataDictionary:
|
|
|
298
309
|
ref_tag=tag,
|
|
299
310
|
)
|
|
300
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
|
+
)
|
|
327
|
+
|
|
301
328
|
def validate_field_value(self, tag: int, value: str) -> bool:
|
|
302
329
|
"""Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
|
|
303
330
|
fd = self._fields_by_number.get(tag)
|
|
@@ -310,7 +337,7 @@ class DataDictionary:
|
|
|
310
337
|
# Cached loading
|
|
311
338
|
# ---------------------------------------------------------------------------
|
|
312
339
|
|
|
313
|
-
@
|
|
340
|
+
@cache
|
|
314
341
|
def _load_cached(resolved_path: str) -> DataDictionary:
|
|
315
342
|
return DataDictionary.from_xml(resolved_path)
|
|
316
343
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections import OrderedDict
|
|
6
|
-
from
|
|
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[
|
|
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:
|
|
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) ->
|
|
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
|
-
) ->
|
|
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.
|
|
@@ -5,10 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from datetime import UTC, datetime
|
|
12
10
|
|
|
13
11
|
from fixcore.application import Application
|
|
14
12
|
from fixcore.log.base import Log
|
|
@@ -52,9 +50,11 @@ from fixcore.session.state import (
|
|
|
52
50
|
)
|
|
53
51
|
from fixcore.store.base import MessageStore
|
|
54
52
|
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
55
|
|
|
56
56
|
def _utc_timestamp() -> str:
|
|
57
|
-
return datetime.now(tz=
|
|
57
|
+
return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S")
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
# Type alias for the transport-provided send callback
|
|
@@ -115,6 +115,11 @@ class Session:
|
|
|
115
115
|
f"{session_id}: UseDataDictionary=Y requires DataDictionary=<path>"
|
|
116
116
|
)
|
|
117
117
|
self._data_dictionary = load_data_dictionary(dd_path)
|
|
118
|
+
# Reject fields whose value is out of the dictionary's enum range (only
|
|
119
|
+
# meaningful when a dictionary is loaded). Opt-in, QuickFIX-compatible.
|
|
120
|
+
self._validate_values: bool = settings.get_bool(
|
|
121
|
+
session_id, "ValidateFieldsOutOfRange", False
|
|
122
|
+
)
|
|
118
123
|
|
|
119
124
|
# Runtime state
|
|
120
125
|
self._state: SessionState = SessionState.DISCONNECTED
|
|
@@ -235,11 +240,15 @@ class Session:
|
|
|
235
240
|
await self._on_reject(msg)
|
|
236
241
|
else:
|
|
237
242
|
if self._state != SessionState.LOGGED_ON:
|
|
238
|
-
self._log.on_event(
|
|
243
|
+
self._log.on_event(
|
|
244
|
+
f"Received app message {msg_type!r} while not logged on — ignoring"
|
|
245
|
+
)
|
|
239
246
|
return
|
|
240
247
|
if self._data_dictionary is not None:
|
|
241
248
|
try:
|
|
242
|
-
self._data_dictionary.validate(
|
|
249
|
+
self._data_dictionary.validate(
|
|
250
|
+
msg, check_field_values=self._validate_values
|
|
251
|
+
)
|
|
243
252
|
except InvalidMessage as exc:
|
|
244
253
|
seq = int(msg.header.get_or(TAG_MSG_SEQ_NUM, "0"))
|
|
245
254
|
self._log.on_event(f"Rejecting {msg_type!r}: {exc}")
|
|
@@ -12,6 +12,13 @@ _DEFAULT_SECTION = "DEFAULT"
|
|
|
12
12
|
_SESSION_SECTION_PREFIX = "SESSION"
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
class _CaseSensitiveParser(configparser.RawConfigParser):
|
|
16
|
+
"""RawConfigParser that preserves option-name case (QuickFIX keys are CamelCase)."""
|
|
17
|
+
|
|
18
|
+
def optionxform(self, optionstr: str) -> str:
|
|
19
|
+
return optionstr
|
|
20
|
+
|
|
21
|
+
|
|
15
22
|
class SessionSettings:
|
|
16
23
|
"""Holds per-session and default configuration values.
|
|
17
24
|
|
|
@@ -36,11 +43,14 @@ class SessionSettings:
|
|
|
36
43
|
|
|
37
44
|
UseDataDictionary=Y ; default N — raw FIX, no validation
|
|
38
45
|
DataDictionary=specs/FIX42.xml ; required when UseDataDictionary=Y
|
|
46
|
+
ValidateFieldsOutOfRange=Y ; default N — also reject out-of-range enums
|
|
39
47
|
|
|
40
48
|
With ``UseDataDictionary=N`` (the default) inbound messages are passed
|
|
41
49
|
through unvalidated. With ``Y`` the named spec is loaded; inbound
|
|
42
|
-
application messages are
|
|
43
|
-
failure.
|
|
50
|
+
application messages are checked for required fields and a session-level
|
|
51
|
+
Reject is returned on failure. ``ValidateFieldsOutOfRange=Y`` additionally
|
|
52
|
+
rejects fields whose value is not a defined enum for that tag (e.g.
|
|
53
|
+
``Side=Z``); it only applies when a DataDictionary is loaded.
|
|
44
54
|
"""
|
|
45
55
|
|
|
46
56
|
def __init__(self) -> None:
|
|
@@ -53,11 +63,10 @@ class SessionSettings:
|
|
|
53
63
|
# ------------------------------------------------------------------
|
|
54
64
|
|
|
55
65
|
@classmethod
|
|
56
|
-
def from_file(cls, path: str | Path) ->
|
|
66
|
+
def from_file(cls, path: str | Path) -> SessionSettings:
|
|
57
67
|
settings = cls()
|
|
58
68
|
text = Path(path).read_text()
|
|
59
|
-
parser =
|
|
60
|
-
parser.optionxform = str # preserve case
|
|
69
|
+
parser = _CaseSensitiveParser()
|
|
61
70
|
import io
|
|
62
71
|
parser.read_file(io.StringIO(cls._preprocess(text)))
|
|
63
72
|
|
|
@@ -95,13 +104,12 @@ class SessionSettings:
|
|
|
95
104
|
return "".join(result)
|
|
96
105
|
|
|
97
106
|
@classmethod
|
|
98
|
-
def from_string(cls, text: str) ->
|
|
107
|
+
def from_string(cls, text: str) -> SessionSettings:
|
|
99
108
|
"""Parse from a configuration string (useful in tests)."""
|
|
100
109
|
import io
|
|
101
110
|
|
|
102
111
|
settings = cls()
|
|
103
|
-
parser =
|
|
104
|
-
parser.optionxform = str
|
|
112
|
+
parser = _CaseSensitiveParser()
|
|
105
113
|
parser.read_file(io.StringIO(cls._preprocess(text)))
|
|
106
114
|
|
|
107
115
|
settings._defaults = dict(parser.defaults())
|
|
@@ -6,8 +6,6 @@ import asyncio
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
from fixcore.application import Application
|
|
9
|
-
|
|
10
|
-
logger = logging.getLogger(__name__)
|
|
11
9
|
from fixcore.log.base import LogFactory
|
|
12
10
|
from fixcore.session.session import Session
|
|
13
11
|
from fixcore.session.session_id import SessionID
|
|
@@ -15,6 +13,8 @@ from fixcore.session.session_settings import SessionSettings
|
|
|
15
13
|
from fixcore.store.factory import StoreFactory
|
|
16
14
|
from fixcore.transport.framer import MessageFramer
|
|
17
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
18
|
|
|
19
19
|
class SocketInitiator:
|
|
20
20
|
"""Dials outbound TCP connections for each configured initiator session.
|
|
@@ -93,7 +93,7 @@ class SocketInitiator:
|
|
|
93
93
|
if session.is_logged_on:
|
|
94
94
|
await session.send_logout()
|
|
95
95
|
|
|
96
|
-
async def __aenter__(self) ->
|
|
96
|
+
async def __aenter__(self) -> SocketInitiator:
|
|
97
97
|
await self.start()
|
|
98
98
|
return self
|
|
99
99
|
|
|
@@ -167,5 +167,5 @@ class SocketInitiator:
|
|
|
167
167
|
"""Sleep for *seconds* but wake immediately if stop is requested."""
|
|
168
168
|
try:
|
|
169
169
|
await asyncio.wait_for(self._stop_event.wait(), timeout=seconds)
|
|
170
|
-
except
|
|
170
|
+
except TimeoutError:
|
|
171
171
|
pass
|
|
@@ -7,9 +7,14 @@ import pytest
|
|
|
7
7
|
from fixcore.application import Application
|
|
8
8
|
from fixcore.message.cracker import MessageCracker
|
|
9
9
|
from fixcore.message.exceptions import UnsupportedMessageType
|
|
10
|
-
from fixcore.message.message import
|
|
10
|
+
from fixcore.message.message import TAG_BEGIN_STRING, TAG_MSG_TYPE, Message
|
|
11
11
|
from fixcore.session.session_id import SessionID
|
|
12
|
-
from fixcore.session.state import
|
|
12
|
+
from fixcore.session.state import (
|
|
13
|
+
TAG_MSG_SEQ_NUM,
|
|
14
|
+
TAG_SENDER_COMP_ID,
|
|
15
|
+
TAG_SENDING_TIME,
|
|
16
|
+
TAG_TARGET_COMP_ID,
|
|
17
|
+
)
|
|
13
18
|
|
|
14
19
|
SID = SessionID("FIX.4.2", "CLIENT", "SERVER")
|
|
15
20
|
|
|
@@ -210,7 +215,9 @@ class TestInstanceRegistration:
|
|
|
210
215
|
app1.register_instance("D", "on_new_order_single")
|
|
211
216
|
|
|
212
217
|
# app2 should not have the instance registry
|
|
213
|
-
assert not hasattr(app2, "_instance_registry") or "D" not in getattr(
|
|
218
|
+
assert not hasattr(app2, "_instance_registry") or "D" not in getattr(
|
|
219
|
+
app2, "_instance_registry", {}
|
|
220
|
+
)
|
|
214
221
|
|
|
215
222
|
|
|
216
223
|
# ---------------------------------------------------------------------------
|
|
@@ -6,7 +6,7 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from fixcore.message.data_dictionary import DataDictionary
|
|
8
8
|
from fixcore.message.exceptions import InvalidMessage
|
|
9
|
-
from fixcore.message.message import
|
|
9
|
+
from fixcore.message.message import TAG_BEGIN_STRING, TAG_MSG_TYPE, Message
|
|
10
10
|
|
|
11
11
|
SPECS = Path(__file__).parent.parent / "specs"
|
|
12
12
|
|
|
@@ -233,6 +233,30 @@ class TestEnumValidation:
|
|
|
233
233
|
def test_no_enum_always_valid(self):
|
|
234
234
|
assert _dd().validate_field_value(55, "anything") # Symbol has no enum
|
|
235
235
|
|
|
236
|
+
def _make_nos(self, side: str = "1") -> Message:
|
|
237
|
+
msg = Message()
|
|
238
|
+
_full_header(msg, "D")
|
|
239
|
+
msg.set_field(11, "ORD001") # ClOrdID
|
|
240
|
+
msg.set_field(21, "1") # HandlInst
|
|
241
|
+
msg.set_field(55, "AAPL") # Symbol
|
|
242
|
+
msg.set_field(54, side) # Side
|
|
243
|
+
msg.set_field(40, "2") # OrdType — Limit
|
|
244
|
+
msg.set_field(60, "20240101-09:30:00") # TransactTime
|
|
245
|
+
return msg
|
|
246
|
+
|
|
247
|
+
def test_validate_rejects_out_of_range_enum(self):
|
|
248
|
+
with pytest.raises(InvalidMessage, match="out of range") as exc_info:
|
|
249
|
+
_dd().validate(self._make_nos(side="Z"), check_field_values=True)
|
|
250
|
+
assert exc_info.value.reason == 5 # value out of range
|
|
251
|
+
assert exc_info.value.ref_tag == 54 # Side
|
|
252
|
+
|
|
253
|
+
def test_validate_accepts_valid_enum(self):
|
|
254
|
+
_dd().validate(self._make_nos(side="1"), check_field_values=True) # no raise
|
|
255
|
+
|
|
256
|
+
def test_validate_skips_enum_check_by_default(self):
|
|
257
|
+
# Without check_field_values an out-of-range enum is not rejected.
|
|
258
|
+
_dd().validate(self._make_nos(side="Z")) # no raise
|
|
259
|
+
|
|
236
260
|
|
|
237
261
|
# ---------------------------------------------------------------------------
|
|
238
262
|
# Full FIX42.xml spec file
|
|
@@ -5,7 +5,6 @@ from pathlib import Path
|
|
|
5
5
|
from fixcore.log.file_log import FileLog, FileLogFactory, _session_filename
|
|
6
6
|
from fixcore.session.session_id import SessionID
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
SID = SessionID("FIX.4.2", "CLIENT", "SERVER")
|
|
10
9
|
SID_Q = SessionID("FIX.4.2", "CLIENT", "SERVER", "Q1")
|
|
11
10
|
|
|
@@ -60,7 +59,7 @@ class TestFileLog:
|
|
|
60
59
|
log.on_outgoing("msg2")
|
|
61
60
|
log.on_event("disconnect")
|
|
62
61
|
log.close()
|
|
63
|
-
lines = [
|
|
62
|
+
lines = [ln for ln in log_file.read_text().splitlines() if ln]
|
|
64
63
|
assert len(lines) == 4
|
|
65
64
|
|
|
66
65
|
def test_appends_across_instances(self, tmp_path):
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
"""Tests for FileStore — persistence, recovery, and sequence number durability."""
|
|
2
2
|
|
|
3
|
-
import pytest
|
|
4
3
|
|
|
5
4
|
from fixcore.session.session_id import SessionID
|
|
6
5
|
from fixcore.store.file_store import FileStore, _session_prefix
|
|
7
6
|
|
|
8
|
-
|
|
9
7
|
SID = SessionID("FIX.4.2", "CLIENT", "SERVER")
|
|
10
8
|
SID_Q = SessionID("FIX.4.2", "CLIENT", "SERVER", "Q1")
|
|
11
9
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""Tests for MessageFramer — stream framing correctness."""
|
|
2
2
|
|
|
3
|
-
from fixcore.message.message import
|
|
4
|
-
from fixcore.session.state import
|
|
3
|
+
from fixcore.message.message import TAG_BEGIN_STRING, TAG_MSG_TYPE, Message
|
|
4
|
+
from fixcore.session.state import (
|
|
5
|
+
TAG_MSG_SEQ_NUM,
|
|
6
|
+
TAG_SENDER_COMP_ID,
|
|
7
|
+
TAG_SENDING_TIME,
|
|
8
|
+
TAG_TARGET_COMP_ID,
|
|
9
|
+
)
|
|
5
10
|
from fixcore.transport.framer import MessageFramer
|
|
6
11
|
|
|
7
12
|
|
|
@@ -16,22 +16,21 @@ Scenarios
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
from collections.abc import Callable
|
|
19
20
|
from pathlib import Path
|
|
20
|
-
from typing import Callable
|
|
21
21
|
|
|
22
22
|
import pytest
|
|
23
23
|
|
|
24
24
|
from fixcore.application import Application
|
|
25
25
|
from fixcore.log.base import Log, LogFactory
|
|
26
|
-
from fixcore.log.file_log import
|
|
26
|
+
from fixcore.log.file_log import FileLogFactory
|
|
27
27
|
from fixcore.message.cracker import MessageCracker
|
|
28
28
|
from fixcore.message.exceptions import UnsupportedMessageType
|
|
29
|
-
from fixcore.message.message import
|
|
29
|
+
from fixcore.message.message import TAG_BEGIN_STRING, TAG_MSG_TYPE, Message
|
|
30
30
|
from fixcore.session.session import Session
|
|
31
31
|
from fixcore.session.session_id import SessionID
|
|
32
32
|
from fixcore.session.session_settings import SessionSettings
|
|
33
33
|
from fixcore.session.state import (
|
|
34
|
-
MSG_HEARTBEAT,
|
|
35
34
|
MSG_RESEND_REQUEST,
|
|
36
35
|
MSG_SEQUENCE_RESET,
|
|
37
36
|
TAG_BEGIN_SEQ_NO,
|
|
@@ -48,7 +47,6 @@ from fixcore.store.memory import MemoryStore
|
|
|
48
47
|
from fixcore.transport.acceptor import SocketAcceptor
|
|
49
48
|
from fixcore.transport.initiator import SocketInitiator
|
|
50
49
|
|
|
51
|
-
|
|
52
50
|
# ---------------------------------------------------------------------------
|
|
53
51
|
# Shared helpers
|
|
54
52
|
# ---------------------------------------------------------------------------
|
|
@@ -568,7 +566,8 @@ class TestMultipleSessions:
|
|
|
568
566
|
|
|
569
567
|
class TrackingApp(Application):
|
|
570
568
|
def on_create(self, s): pass
|
|
571
|
-
def on_logon(self, sid: SessionID):
|
|
569
|
+
def on_logon(self, sid: SessionID):
|
|
570
|
+
logons[sid.target_comp_id] = logons.get(sid.target_comp_id, 0) + 1
|
|
572
571
|
def on_logout(self, s): pass
|
|
573
572
|
def to_admin(self, m, s): pass
|
|
574
573
|
def to_app(self, m, s): pass
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from fixcore.message.message import
|
|
5
|
+
from fixcore.message.message import (
|
|
6
|
+
TAG_BEGIN_STRING,
|
|
7
|
+
TAG_MSG_TYPE,
|
|
8
|
+
Message,
|
|
9
|
+
)
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
def _make_heartbeat() -> Message:
|
|
@@ -150,6 +154,7 @@ class TestGroups:
|
|
|
150
154
|
|
|
151
155
|
def test_decode_with_dd_extracts_groups(self):
|
|
152
156
|
from pathlib import Path
|
|
157
|
+
|
|
153
158
|
from fixcore.message.data_dictionary import DataDictionary
|
|
154
159
|
|
|
155
160
|
dd = DataDictionary.from_xml(Path("specs/FIX42.xml"))
|
|
@@ -165,6 +170,7 @@ class TestGroups:
|
|
|
165
170
|
|
|
166
171
|
def test_roundtrip_with_groups(self):
|
|
167
172
|
from pathlib import Path
|
|
173
|
+
|
|
168
174
|
from fixcore.message.data_dictionary import DataDictionary
|
|
169
175
|
|
|
170
176
|
dd = DataDictionary.from_xml(Path("specs/FIX42.xml"))
|
|
@@ -182,6 +188,7 @@ class TestGroups:
|
|
|
182
188
|
def test_empty_group(self):
|
|
183
189
|
"""Count tag = 0 produces no instances on decode."""
|
|
184
190
|
from pathlib import Path
|
|
191
|
+
|
|
185
192
|
from fixcore.message.data_dictionary import DataDictionary
|
|
186
193
|
|
|
187
194
|
dd = DataDictionary.from_xml(Path("specs/FIX42.xml"))
|
|
@@ -37,9 +37,9 @@ from fixcore.session.state import (
|
|
|
37
37
|
TAG_REF_SEQ_NUM,
|
|
38
38
|
TAG_REF_TAG_ID,
|
|
39
39
|
TAG_RESET_SEQ_NUM_FLAG,
|
|
40
|
-
TAG_SESSION_REJECT_REASON,
|
|
41
40
|
TAG_SENDER_COMP_ID,
|
|
42
41
|
TAG_SENDING_TIME,
|
|
42
|
+
TAG_SESSION_REJECT_REASON,
|
|
43
43
|
TAG_TARGET_COMP_ID,
|
|
44
44
|
TAG_TEST_REQ_ID,
|
|
45
45
|
TAG_TEXT,
|
|
@@ -684,14 +684,19 @@ VALIDATING_CFG = (
|
|
|
684
684
|
+ f"DataDictionary={_SPEC_PATH}\n"
|
|
685
685
|
)
|
|
686
686
|
|
|
687
|
+
# Also reject out-of-range enum values.
|
|
688
|
+
VALIDATING_RANGE_CFG = VALIDATING_CFG + "ValidateFieldsOutOfRange=Y\n"
|
|
687
689
|
|
|
688
|
-
|
|
690
|
+
|
|
691
|
+
def _new_order_single(
|
|
692
|
+
seq: int, *, omit_ord_type: bool = False, side: str = "1"
|
|
693
|
+
) -> bytes:
|
|
689
694
|
"""Build an inbound NewOrderSingle (msgtype D) with FIX42 required fields."""
|
|
690
695
|
fields = {
|
|
691
696
|
"11": "ORD001", # ClOrdID
|
|
692
697
|
"21": "1", # HandlInst
|
|
693
698
|
"55": "AAPL", # Symbol
|
|
694
|
-
"54":
|
|
699
|
+
"54": side, # Side
|
|
695
700
|
"60": "20240101-00:00:00", # TransactTime
|
|
696
701
|
"40": "2", # OrdType
|
|
697
702
|
}
|
|
@@ -744,6 +749,25 @@ class TestDataDictionaryValidation:
|
|
|
744
749
|
with pytest.raises(ValueError, match="DataDictionary"):
|
|
745
750
|
_make_session(cfg, SID_CLIENT)
|
|
746
751
|
|
|
752
|
+
async def test_out_of_range_enum_rejected_when_enabled(self):
|
|
753
|
+
session, app, sink = await self._logged_on(VALIDATING_RANGE_CFG)
|
|
754
|
+
await session.on_data(_new_order_single(2, side="Z")) # invalid Side
|
|
755
|
+
|
|
756
|
+
assert app.from_app_msgs == []
|
|
757
|
+
assert MSG_REJECT in sink.msg_types()
|
|
758
|
+
reject = next(m for m in sink.decoded() if m.msg_type == MSG_REJECT)
|
|
759
|
+
assert reject.get_field(TAG_REF_TAG_ID) == "54" # Side
|
|
760
|
+
assert reject.get_field(TAG_SESSION_REJECT_REASON) == "5" # value out of range
|
|
761
|
+
await session.on_disconnect()
|
|
762
|
+
|
|
763
|
+
async def test_out_of_range_enum_allowed_when_switch_off(self):
|
|
764
|
+
# ValidateFieldsOutOfRange defaults off — bad enum is delivered as-is.
|
|
765
|
+
session, app, sink = await self._logged_on(VALIDATING_CFG)
|
|
766
|
+
await session.on_data(_new_order_single(2, side="Z"))
|
|
767
|
+
assert len(app.from_app_msgs) == 1
|
|
768
|
+
assert MSG_REJECT not in sink.msg_types()
|
|
769
|
+
await session.on_disconnect()
|
|
770
|
+
|
|
747
771
|
|
|
748
772
|
# ---------------------------------------------------------------------------
|
|
749
773
|
# Send serialisation (concurrent sends do not interleave)
|
|
@@ -3,20 +3,17 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
6
|
+
from collections.abc import Callable
|
|
8
7
|
|
|
9
8
|
from fixcore.application import Application
|
|
10
9
|
from fixcore.log.base import Log, LogFactory
|
|
11
10
|
from fixcore.message.message import Message
|
|
12
11
|
from fixcore.session.session_id import SessionID
|
|
13
12
|
from fixcore.session.session_settings import SessionSettings
|
|
14
|
-
from fixcore.session.state import SessionState
|
|
15
13
|
from fixcore.store.factory import MemoryStoreFactory
|
|
16
14
|
from fixcore.transport.acceptor import SocketAcceptor
|
|
17
15
|
from fixcore.transport.initiator import SocketInitiator
|
|
18
16
|
|
|
19
|
-
|
|
20
17
|
# ---------------------------------------------------------------------------
|
|
21
18
|
# Test doubles
|
|
22
19
|
# ---------------------------------------------------------------------------
|
|
@@ -91,7 +88,7 @@ async def _free_port() -> int:
|
|
|
91
88
|
return s.getsockname()[1]
|
|
92
89
|
|
|
93
90
|
|
|
94
|
-
async def _wait_for(condition:
|
|
91
|
+
async def _wait_for(condition: Callable[[], bool], timeout: float = 5.0) -> None:
|
|
95
92
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
96
93
|
while not condition():
|
|
97
94
|
if asyncio.get_event_loop().time() > deadline:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|