refix-engine 0.0.1__tar.gz → 0.1.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.
- {refix_engine-0.0.1 → refix_engine-0.1.0}/Cargo.lock +3 -3
- {refix_engine-0.0.1 → refix_engine-0.1.0}/Cargo.toml +1 -1
- {refix_engine-0.0.1 → refix_engine-0.1.0}/PKG-INFO +1 -1
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/Cargo.toml +1 -1
- refix_engine-0.1.0/crates/refix-message/src/length_tags.rs +25 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/lib.rs +3 -0
- refix_engine-0.1.0/crates/refix-message/src/stream.rs +229 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/tokenizer.rs +216 -35
- {refix_engine-0.0.1 → refix_engine-0.1.0}/pyproject.toml +1 -0
- refix_engine-0.1.0/python/refix/__init__.py +6 -0
- refix_engine-0.1.0/python/refix/_core.pyi +41 -0
- refix_engine-0.1.0/python/refix/log.py +30 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/Cargo.toml +1 -1
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/src/lib.rs +3 -0
- refix_engine-0.1.0/python/refix-engine/src/stream.rs +58 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/src/tokenizer.rs +4 -3
- refix_engine-0.1.0/python/refix-engine/tests/test_benchmarks.py +57 -0
- refix_engine-0.1.0/python/refix-engine/tests/test_log.py +74 -0
- refix_engine-0.1.0/python/refix-engine/tests/test_stream.py +129 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/tests/test_tokenizer.py +34 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/uv.lock +24 -0
- refix_engine-0.0.1/python/refix/__init__.py +0 -5
- refix_engine-0.0.1/python/refix/_core.pyi +0 -20
- {refix_engine-0.0.1 → refix_engine-0.1.0}/LICENSE-APACHE +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/LICENSE-MIT +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/README.md +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/LICENSE-APACHE +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/LICENSE-MIT +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/README.md +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/framing.rs +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/message.rs +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/test_utils.rs +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix/errors.py +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix/py.typed +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/.python-version +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/LICENSE-APACHE +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/LICENSE-MIT +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/README.md +0 -0
- {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/src/message.rs +0 -0
|
@@ -109,14 +109,14 @@ dependencies = [
|
|
|
109
109
|
|
|
110
110
|
[[package]]
|
|
111
111
|
name = "refix"
|
|
112
|
-
version = "0.0
|
|
112
|
+
version = "0.1.0"
|
|
113
113
|
dependencies = [
|
|
114
114
|
"refix-message",
|
|
115
115
|
]
|
|
116
116
|
|
|
117
117
|
[[package]]
|
|
118
118
|
name = "refix-engine"
|
|
119
|
-
version = "0.0
|
|
119
|
+
version = "0.1.0"
|
|
120
120
|
dependencies = [
|
|
121
121
|
"bytes",
|
|
122
122
|
"pyo3",
|
|
@@ -125,7 +125,7 @@ dependencies = [
|
|
|
125
125
|
|
|
126
126
|
[[package]]
|
|
127
127
|
name = "refix-message"
|
|
128
|
-
version = "0.
|
|
128
|
+
version = "0.2.0"
|
|
129
129
|
dependencies = [
|
|
130
130
|
"bytes",
|
|
131
131
|
]
|
|
@@ -15,5 +15,5 @@ categories = ["encoding", "parsing", "finance"]
|
|
|
15
15
|
[workspace.dependencies]
|
|
16
16
|
bytes = "1.12.1"
|
|
17
17
|
pyo3 = "0.29.0"
|
|
18
|
-
refix-message = { version = "0.
|
|
18
|
+
refix-message = { version = "0.2.0", path = "crates/refix-message" }
|
|
19
19
|
serde = "1.0.229"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// Length tags whose value gives the byte count of the immediately following
|
|
2
|
+
/// data field.
|
|
3
|
+
///
|
|
4
|
+
/// Derived from the QuickFIX FIX 4.0 - 5.0 SP2 dictionaries:
|
|
5
|
+
/// every LENGTH field paired with a DATA field.
|
|
6
|
+
///
|
|
7
|
+
/// *Note*: these values must remain ordered.
|
|
8
|
+
pub(crate) const STANDARD: &[u32] = &[
|
|
9
|
+
90, 93, 95, 212, 348, 350, 352, 354, 356, 358, 360, 362, 364, 445, 618, 621, 1184, 1277, 1280,
|
|
10
|
+
1282, 1397, 1401, 1403, 1468, 1525, 1578, 1620, 1664, 1678, 1733, 1871, 1874, 2072, 2074, 2111,
|
|
11
|
+
2179, 2287, 2351, 2372, 2481, 2494, 2522, 2637, 2651, 2665, 2715, 2718, 2721, 2797, 2802, 2809,
|
|
12
|
+
2815, 40004, 40008, 40978, 40980, 40982, 40984, 40986, 40988, 41083, 41101, 41107, 41256,
|
|
13
|
+
41320, 41324, 41458, 41476, 41482, 41653, 41710, 41806, 41811, 41873, 41969, 42025, 42171,
|
|
14
|
+
42451, 42652, 42947, 43109, 43110, 43111,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
#[cfg(test)]
|
|
18
|
+
mod tests {
|
|
19
|
+
use super::*;
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn standard_is_sorted_and_unique() {
|
|
23
|
+
assert!(STANDARD.windows(2).all(|pair| pair[0] < pair[1]));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
pub mod framing;
|
|
2
|
+
mod length_tags;
|
|
2
3
|
mod message;
|
|
4
|
+
pub mod stream;
|
|
3
5
|
#[cfg(test)]
|
|
4
6
|
mod test_utils;
|
|
5
7
|
mod tokenizer;
|
|
6
8
|
|
|
7
9
|
pub use self::message::{MALFORMED_TAG, RawMessage};
|
|
10
|
+
pub use self::stream::MessageStream;
|
|
8
11
|
pub use self::tokenizer::{TokenizeError, Tokenizer};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
use crate::framing::{GarbledReason, Outcome as FramingOutcome, Scanner};
|
|
2
|
+
use crate::{RawMessage, Tokenizer};
|
|
3
|
+
use bytes::{Bytes, BytesMut};
|
|
4
|
+
|
|
5
|
+
/// Reads a stream of FIX messages from incrementally fed bytes.
|
|
6
|
+
#[derive(Default)]
|
|
7
|
+
pub struct MessageStream {
|
|
8
|
+
tokenizer: Tokenizer,
|
|
9
|
+
scanner: Scanner,
|
|
10
|
+
buf: BytesMut,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl MessageStream {
|
|
14
|
+
/// Creates a stream that tokenises with `tokenizer`.
|
|
15
|
+
pub fn new(tokenizer: Tokenizer) -> Self {
|
|
16
|
+
Self {
|
|
17
|
+
tokenizer,
|
|
18
|
+
scanner: Scanner::default(),
|
|
19
|
+
buf: BytesMut::new(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Appends bytes to the stream's buffer.
|
|
24
|
+
pub fn feed(&mut self, bytes: &[u8]) {
|
|
25
|
+
self.buf.extend_from_slice(bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Consumes and returns the next outcome at the front of the buffer.
|
|
29
|
+
pub fn next_message(&mut self) -> Outcome {
|
|
30
|
+
match self.scanner.scan(&self.buf) {
|
|
31
|
+
FramingOutcome::Frame(frame) => {
|
|
32
|
+
let len = frame.bytes.len();
|
|
33
|
+
let bytes = self.buf.split_to(len).freeze();
|
|
34
|
+
let fields = self.tokenizer.tokenize_fields(&bytes);
|
|
35
|
+
Outcome::Message(RawMessage::new(bytes, fields))
|
|
36
|
+
}
|
|
37
|
+
FramingOutcome::Incomplete => Outcome::Incomplete,
|
|
38
|
+
FramingOutcome::Garbled { reason, skipped } => Outcome::Garbled {
|
|
39
|
+
reason,
|
|
40
|
+
bytes: self.buf.split_to(skipped).freeze(),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// One step of reading the stream.
|
|
47
|
+
pub enum Outcome {
|
|
48
|
+
/// A complete, checksum-verified message, consumed from the buffer.
|
|
49
|
+
Message(RawMessage),
|
|
50
|
+
/// Bytes that cannot begin a message, consumed from the buffer.
|
|
51
|
+
Garbled { reason: GarbledReason, bytes: Bytes },
|
|
52
|
+
/// The buffer holds no complete message, feed more bytes.
|
|
53
|
+
Incomplete,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[cfg(test)]
|
|
57
|
+
mod tests {
|
|
58
|
+
use super::*;
|
|
59
|
+
use crate::Tokenizer;
|
|
60
|
+
use crate::test_utils::construct_valid_frame;
|
|
61
|
+
|
|
62
|
+
/// Collects outcomes until the stream reports `Incomplete`.
|
|
63
|
+
fn drain(stream: &mut MessageStream) -> Vec<Outcome> {
|
|
64
|
+
let mut outcomes = Vec::new();
|
|
65
|
+
loop {
|
|
66
|
+
match stream.next_message() {
|
|
67
|
+
Outcome::Incomplete => return outcomes,
|
|
68
|
+
outcome => outcomes.push(outcome),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[track_caller]
|
|
74
|
+
fn expect_message(outcome: &Outcome) -> &RawMessage {
|
|
75
|
+
match outcome {
|
|
76
|
+
Outcome::Message(message) => message,
|
|
77
|
+
Outcome::Garbled { .. } => panic!("expected a message, got a garble"),
|
|
78
|
+
Outcome::Incomplete => panic!("expected a message, got Incomplete"),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[track_caller]
|
|
83
|
+
fn expect_garble(outcome: &Outcome) -> (GarbledReason, &Bytes) {
|
|
84
|
+
match outcome {
|
|
85
|
+
Outcome::Garbled { reason, bytes } => (*reason, bytes),
|
|
86
|
+
Outcome::Message(_) => panic!("expected a garble, got a message"),
|
|
87
|
+
Outcome::Incomplete => panic!("expected a garble, got Incomplete"),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
fn empty_stream_is_incomplete() {
|
|
93
|
+
let mut stream = MessageStream::default();
|
|
94
|
+
assert!(matches!(stream.next_message(), Outcome::Incomplete));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn single_message() {
|
|
99
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|58=hi|");
|
|
100
|
+
let mut stream = MessageStream::default();
|
|
101
|
+
stream.feed(&frame);
|
|
102
|
+
|
|
103
|
+
let outcomes = drain(&mut stream);
|
|
104
|
+
|
|
105
|
+
assert_eq!(outcomes.len(), 1);
|
|
106
|
+
assert_eq!(expect_message(&outcomes[0]).bytes(), &frame);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[test]
|
|
110
|
+
fn several_messages_in_one_feed() {
|
|
111
|
+
let first = construct_valid_frame("FIX.4.4", "35=0|58=first|");
|
|
112
|
+
let second = construct_valid_frame("FIX.4.4", "35=0|58=second|");
|
|
113
|
+
let mut stream = MessageStream::default();
|
|
114
|
+
stream.feed(&first);
|
|
115
|
+
stream.feed(&second);
|
|
116
|
+
|
|
117
|
+
let outcomes = drain(&mut stream);
|
|
118
|
+
|
|
119
|
+
assert_eq!(outcomes.len(), 2);
|
|
120
|
+
assert_eq!(expect_message(&outcomes[0]).bytes(), &first);
|
|
121
|
+
assert_eq!(expect_message(&outcomes[1]).bytes(), &second);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Splitting a frame at every byte boundary covers every boundary class:
|
|
125
|
+
/// mid-preamble, mid-tag, mid-body and mid-data-field. The data field's
|
|
126
|
+
/// extent must survive the split.
|
|
127
|
+
#[test]
|
|
128
|
+
fn message_completes_across_feeds_at_every_split() {
|
|
129
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|95=3|96=a|b|58=ok|");
|
|
130
|
+
for split in 1..frame.len() {
|
|
131
|
+
let mut stream = MessageStream::default();
|
|
132
|
+
stream.feed(&frame[..split]);
|
|
133
|
+
assert!(
|
|
134
|
+
matches!(stream.next_message(), Outcome::Incomplete),
|
|
135
|
+
"prefix of {split} bytes must be incomplete",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
stream.feed(&frame[split..]);
|
|
139
|
+
let outcomes = drain(&mut stream);
|
|
140
|
+
assert_eq!(outcomes.len(), 1, "split at {split}");
|
|
141
|
+
let message = expect_message(&outcomes[0]);
|
|
142
|
+
assert_eq!(message.bytes(), &frame, "split at {split}");
|
|
143
|
+
assert_eq!(message.get(96), Some(b"a\x01b".as_slice()));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[test]
|
|
148
|
+
fn dialect_extras_flow_through() {
|
|
149
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|5001=3|5002=a|b|");
|
|
150
|
+
let mut stream = MessageStream::new(Tokenizer::with_extra_length_tags([5001]));
|
|
151
|
+
stream.feed(&frame);
|
|
152
|
+
|
|
153
|
+
let outcomes = drain(&mut stream);
|
|
154
|
+
|
|
155
|
+
assert_eq!(
|
|
156
|
+
expect_message(&outcomes[0]).get(5002),
|
|
157
|
+
Some(b"a\x01b".as_slice())
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn garbage_between_messages() {
|
|
163
|
+
let first = construct_valid_frame("FIX.4.4", "35=0|58=first|");
|
|
164
|
+
let second = construct_valid_frame("FIX.4.4", "35=0|58=second|");
|
|
165
|
+
let mut stream = MessageStream::default();
|
|
166
|
+
stream.feed(&first);
|
|
167
|
+
stream.feed(b"YY8=Z");
|
|
168
|
+
stream.feed(&second);
|
|
169
|
+
|
|
170
|
+
let outcomes = drain(&mut stream);
|
|
171
|
+
|
|
172
|
+
assert_eq!(outcomes.len(), 3);
|
|
173
|
+
assert_eq!(expect_message(&outcomes[0]).bytes(), &first);
|
|
174
|
+
let (reason, bytes) = expect_garble(&outcomes[1]);
|
|
175
|
+
assert_eq!(reason, GarbledReason::MissingBeginString);
|
|
176
|
+
assert_eq!(bytes.as_ref(), b"YY8=Z");
|
|
177
|
+
assert_eq!(expect_message(&outcomes[2]).bytes(), &second);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn leading_garbage_is_skipped() {
|
|
182
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|");
|
|
183
|
+
let mut stream = MessageStream::default();
|
|
184
|
+
stream.feed(b"junk");
|
|
185
|
+
stream.feed(&frame);
|
|
186
|
+
|
|
187
|
+
let outcomes = drain(&mut stream);
|
|
188
|
+
|
|
189
|
+
assert_eq!(outcomes.len(), 2);
|
|
190
|
+
let (reason, bytes) = expect_garble(&outcomes[0]);
|
|
191
|
+
assert_eq!(reason, GarbledReason::MissingBeginString);
|
|
192
|
+
assert_eq!(bytes.as_ref(), b"junk");
|
|
193
|
+
assert_eq!(expect_message(&outcomes[1]).bytes(), &frame);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[test]
|
|
197
|
+
fn garbled_frame_does_not_stall_the_stream() {
|
|
198
|
+
let mut bad = construct_valid_frame("FIX.4.4", "35=0|58=bad|");
|
|
199
|
+
let last_digit = bad.len() - 2;
|
|
200
|
+
bad[last_digit] = if bad[last_digit] == b'0' { b'1' } else { b'0' };
|
|
201
|
+
let good = construct_valid_frame("FIX.4.4", "35=0|58=good|");
|
|
202
|
+
let mut stream = MessageStream::default();
|
|
203
|
+
stream.feed(&bad);
|
|
204
|
+
stream.feed(&good);
|
|
205
|
+
|
|
206
|
+
let outcomes = drain(&mut stream);
|
|
207
|
+
|
|
208
|
+
let (reason, _) = expect_garble(&outcomes[0]);
|
|
209
|
+
assert_eq!(reason, GarbledReason::ChecksumMismatch);
|
|
210
|
+
let last = outcomes.last().unwrap();
|
|
211
|
+
assert_eq!(expect_message(last).bytes(), &good);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// An oversized frame is a garble like any other, not a terminal state.
|
|
215
|
+
#[test]
|
|
216
|
+
fn oversized_frame_is_skipped_not_fatal() {
|
|
217
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|");
|
|
218
|
+
let mut stream = MessageStream::default();
|
|
219
|
+
stream.feed(b"8=FIX.4.4\x019=1048577\x01");
|
|
220
|
+
stream.feed(&frame);
|
|
221
|
+
|
|
222
|
+
let outcomes = drain(&mut stream);
|
|
223
|
+
|
|
224
|
+
let (reason, _) = expect_garble(&outcomes[0]);
|
|
225
|
+
assert_eq!(reason, GarbledReason::FrameTooLarge);
|
|
226
|
+
let last = outcomes.last().unwrap();
|
|
227
|
+
assert_eq!(expect_message(last).bytes(), &frame);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -1,58 +1,106 @@
|
|
|
1
1
|
use crate::framing::{GarbledReason, Outcome, SOH, Scanner};
|
|
2
2
|
use crate::message::RawField;
|
|
3
|
-
use crate::{MALFORMED_TAG, RawMessage};
|
|
3
|
+
use crate::{MALFORMED_TAG, RawMessage, length_tags};
|
|
4
4
|
use bytes::Bytes;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/// Splits a framed FIX message into its fields.
|
|
7
|
+
#[derive(Clone, Debug)]
|
|
8
|
+
pub struct Tokenizer {
|
|
9
|
+
length_tags: Vec<u32>,
|
|
10
|
+
}
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Incomplete,
|
|
16
|
-
/// One message was found, but the input continues past it.
|
|
17
|
-
TrailingBytes { frame_len: usize },
|
|
18
|
-
/// The frame is too long for the index's `u32` offsets to address.
|
|
19
|
-
TooLargeToIndex,
|
|
12
|
+
impl Default for Tokenizer {
|
|
13
|
+
fn default() -> Self {
|
|
14
|
+
Self {
|
|
15
|
+
length_tags: length_tags::STANDARD.to_vec(),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
impl Tokenizer {
|
|
21
|
+
/// Constructs a [`Tokenizer`] with additional (non-standard) length tags.
|
|
22
|
+
///
|
|
23
|
+
/// The supplied values are additive, they never replace the standard set.
|
|
24
|
+
/// Tag 0 is reserved as the malformed-field sentinel and is ignored.
|
|
25
|
+
pub fn with_extra_length_tags(extras: impl IntoIterator<Item = u32>) -> Self {
|
|
26
|
+
let mut length_tags: Vec<u32> = length_tags::STANDARD
|
|
27
|
+
.iter()
|
|
28
|
+
.copied()
|
|
29
|
+
.chain(extras.into_iter().filter(|&tag| tag != MALFORMED_TAG))
|
|
30
|
+
.collect();
|
|
31
|
+
length_tags.sort_unstable();
|
|
32
|
+
length_tags.dedup();
|
|
33
|
+
Self { length_tags }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Tokenises exactly one complete FIX message into a [`RawMessage`].
|
|
37
|
+
///
|
|
38
|
+
/// The bytes must contain a single well-framed message.
|
|
39
|
+
/// Frame-level issues surface as [`TokenizeError`], while malformed
|
|
40
|
+
/// fields inside a valid frame are indexed under [`MALFORMED_TAG`].
|
|
23
41
|
pub fn tokenize(&self, bytes: Bytes) -> Result<RawMessage, TokenizeError> {
|
|
24
42
|
check_frame(&bytes)?;
|
|
25
|
-
let fields = tokenize_fields(&bytes);
|
|
43
|
+
let fields = self.tokenize_fields(&bytes);
|
|
26
44
|
|
|
27
45
|
Ok(RawMessage::new(bytes, fields))
|
|
28
46
|
}
|
|
29
|
-
}
|
|
30
47
|
|
|
31
|
-
fn tokenize_fields(bytes: &[u8]) -> Vec<RawField> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
pub(crate) fn tokenize_fields(&self, bytes: &[u8]) -> Vec<RawField> {
|
|
49
|
+
let mut fields = Vec::with_capacity(bytes.len() / 8);
|
|
50
|
+
let mut pos = 0;
|
|
51
|
+
let mut data_len: Option<usize> = None;
|
|
52
|
+
|
|
53
|
+
while let Some((field, next)) = next_field(bytes, pos, data_len.take()) {
|
|
54
|
+
data_len = self.pending_data_len(bytes, field);
|
|
55
|
+
fields.push(field);
|
|
56
|
+
pos = next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fields
|
|
60
|
+
}
|
|
35
61
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
/// The value of `field` when it is a well-formed length tag, so the next
|
|
63
|
+
/// field's value can be read by extent instead of scanned for SOH.
|
|
64
|
+
fn pending_data_len(&self, bytes: &[u8], field: RawField) -> Option<usize> {
|
|
65
|
+
if !self.is_length_tag(field.tag) {
|
|
66
|
+
return None;
|
|
67
|
+
}
|
|
68
|
+
let value = &bytes[field.value_start as usize..field.value_end as usize];
|
|
69
|
+
parse_u32(value).map(|len| len as usize)
|
|
39
70
|
}
|
|
40
71
|
|
|
41
|
-
|
|
72
|
+
fn is_length_tag(&self, tag: u32) -> bool {
|
|
73
|
+
self.length_tags.first().is_some_and(|&min| tag >= min)
|
|
74
|
+
&& self.length_tags.binary_search(&tag).is_ok()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Issues that can arise when the bytes handed to the tokeniser are not a valid FIX message.
|
|
79
|
+
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
80
|
+
pub enum TokenizeError {
|
|
81
|
+
/// The bytes fail the frame-level checks.
|
|
82
|
+
Garbled(GarbledReason),
|
|
83
|
+
/// The bytes end mid-message.
|
|
84
|
+
Incomplete,
|
|
85
|
+
/// One message was found, but the input continues past it.
|
|
86
|
+
TrailingBytes { frame_len: usize },
|
|
87
|
+
/// The frame is too long for the index's `u32` offsets to address.
|
|
88
|
+
TooLargeToIndex,
|
|
42
89
|
}
|
|
43
90
|
|
|
44
|
-
fn next_field(bytes: &[u8], pos: usize) -> Option<(RawField, usize)> {
|
|
91
|
+
fn next_field(bytes: &[u8], pos: usize, data_len: Option<usize>) -> Option<(RawField, usize)> {
|
|
45
92
|
match find_tag_end(bytes, pos) {
|
|
46
93
|
None => None,
|
|
47
94
|
Some(TagEnd::Equals(delimiter_pos)) => {
|
|
48
|
-
let value_end = match find_soh(bytes, delimiter_pos) {
|
|
49
|
-
None => bytes.len(),
|
|
50
|
-
Some(end) => end,
|
|
51
|
-
};
|
|
52
95
|
let tag = parse_u32(&bytes[pos..delimiter_pos]).unwrap_or(MALFORMED_TAG);
|
|
96
|
+
let value_start = delimiter_pos + 1;
|
|
97
|
+
let value_end = data_len
|
|
98
|
+
.and_then(|len| data_value_end(bytes, value_start, len))
|
|
99
|
+
.or_else(|| find_soh(bytes, value_start))
|
|
100
|
+
.unwrap_or(bytes.len());
|
|
53
101
|
let field = RawField {
|
|
54
102
|
tag,
|
|
55
|
-
value_start:
|
|
103
|
+
value_start: value_start as u32,
|
|
56
104
|
value_end: value_end as u32,
|
|
57
105
|
};
|
|
58
106
|
|
|
@@ -113,6 +161,16 @@ fn find_soh(bytes: &[u8], from: usize) -> Option<usize> {
|
|
|
113
161
|
.map(|rel| from + rel)
|
|
114
162
|
}
|
|
115
163
|
|
|
164
|
+
/// End of a length-delimited value.
|
|
165
|
+
///
|
|
166
|
+
/// Returns `None` unless the byte immediately after the claimed extent is SOH.
|
|
167
|
+
/// A length that overruns the frame or lands mid-value is distrusted and the
|
|
168
|
+
/// caller falls back to SOH scanning.
|
|
169
|
+
fn data_value_end(bytes: &[u8], value_start: usize, len: usize) -> Option<usize> {
|
|
170
|
+
let end = value_start.checked_add(len)?;
|
|
171
|
+
(bytes.get(end) == Some(&SOH)).then_some(end)
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
fn check_frame(bytes: &Bytes) -> Result<(), TokenizeError> {
|
|
117
175
|
let scanner = Scanner::new(u32::MAX as usize);
|
|
118
176
|
match scanner.scan(bytes) {
|
|
@@ -145,7 +203,7 @@ mod tests {
|
|
|
145
203
|
#[track_caller]
|
|
146
204
|
fn tokenize_body(body: &str) -> RawMessage {
|
|
147
205
|
let frame = construct_valid_frame("FIX.4.4", body);
|
|
148
|
-
let message = Tokenizer.tokenize(Bytes::from(frame)).unwrap();
|
|
206
|
+
let message = Tokenizer::default().tokenize(Bytes::from(frame)).unwrap();
|
|
149
207
|
assert_tiles(&message);
|
|
150
208
|
message
|
|
151
209
|
}
|
|
@@ -216,7 +274,7 @@ mod tests {
|
|
|
216
274
|
#[test]
|
|
217
275
|
fn valid_message() {
|
|
218
276
|
let frame = to_wire("8=FIX.4.4|9=41|35=0|49=A|56=B|34=1|52=20260730-10:00:00|10=123|");
|
|
219
|
-
let message = Tokenizer.tokenize(Bytes::from(frame)).unwrap();
|
|
277
|
+
let message = Tokenizer::default().tokenize(Bytes::from(frame)).unwrap();
|
|
220
278
|
assert_tiles(&message);
|
|
221
279
|
|
|
222
280
|
let expected_fields = [
|
|
@@ -269,7 +327,9 @@ mod tests {
|
|
|
269
327
|
let mut bytes = construct_valid_frame("FIX.4.4", "35=0|");
|
|
270
328
|
bytes.pop();
|
|
271
329
|
|
|
272
|
-
let error = Tokenizer
|
|
330
|
+
let error = Tokenizer::default()
|
|
331
|
+
.tokenize(Bytes::from(bytes))
|
|
332
|
+
.unwrap_err();
|
|
273
333
|
assert_eq!(error, TokenizeError::Incomplete);
|
|
274
334
|
}
|
|
275
335
|
|
|
@@ -277,7 +337,9 @@ mod tests {
|
|
|
277
337
|
fn absurd_body_length_is_too_large_to_index() {
|
|
278
338
|
let bytes = to_wire("8=FIX.4.4|9=4294967295|35=0|");
|
|
279
339
|
|
|
280
|
-
let error = Tokenizer
|
|
340
|
+
let error = Tokenizer::default()
|
|
341
|
+
.tokenize(Bytes::from(bytes))
|
|
342
|
+
.unwrap_err();
|
|
281
343
|
assert_eq!(error, TokenizeError::TooLargeToIndex);
|
|
282
344
|
}
|
|
283
345
|
|
|
@@ -287,7 +349,9 @@ mod tests {
|
|
|
287
349
|
let frame_len = bytes.len();
|
|
288
350
|
bytes.extend_from_slice(&construct_valid_frame("FIX.4.4", "35=0|"));
|
|
289
351
|
|
|
290
|
-
let error = Tokenizer
|
|
352
|
+
let error = Tokenizer::default()
|
|
353
|
+
.tokenize(Bytes::from(bytes))
|
|
354
|
+
.unwrap_err();
|
|
291
355
|
assert_eq!(error, TokenizeError::TrailingBytes { frame_len });
|
|
292
356
|
}
|
|
293
357
|
}
|
|
@@ -347,4 +411,121 @@ mod tests {
|
|
|
347
411
|
);
|
|
348
412
|
}
|
|
349
413
|
}
|
|
414
|
+
|
|
415
|
+
/// Tests for length-delimited data fields, whose values may contain SOH.
|
|
416
|
+
mod data_fields {
|
|
417
|
+
use super::*;
|
|
418
|
+
use crate::message::MALFORMED_TAG;
|
|
419
|
+
|
|
420
|
+
#[test]
|
|
421
|
+
fn value_with_embedded_soh() {
|
|
422
|
+
let message = tokenize_body("35=0|95=3|96=a|b|58=ok|");
|
|
423
|
+
assert_body_entries(
|
|
424
|
+
&message,
|
|
425
|
+
&[(35, "0"), (95, "3"), (96, "a\x01b"), (58, "ok")],
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn value_with_several_sohs() {
|
|
431
|
+
let message = tokenize_body("35=0|95=5|96=a|b|c|58=ok|");
|
|
432
|
+
assert_body_entries(
|
|
433
|
+
&message,
|
|
434
|
+
&[(35, "0"), (95, "5"), (96, "a\x01b\x01c"), (58, "ok")],
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn zero_length_value() {
|
|
440
|
+
let message = tokenize_body("35=0|95=0|96=|58=ok|");
|
|
441
|
+
assert_body_entries(&message, &[(35, "0"), (95, "0"), (96, ""), (58, "ok")]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#[test]
|
|
445
|
+
fn data_value_last_in_body() {
|
|
446
|
+
let message = tokenize_body("35=0|95=3|96=a|b|");
|
|
447
|
+
assert_body_entries(&message, &[(35, "0"), (95, "3"), (96, "a\x01b")]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn overrunning_length_is_distrusted() {
|
|
452
|
+
let message = tokenize_body("35=0|95=4294967295|96=ab|58=ok|");
|
|
453
|
+
assert_body_entries(
|
|
454
|
+
&message,
|
|
455
|
+
&[(35, "0"), (95, "4294967295"), (96, "ab"), (58, "ok")],
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[test]
|
|
460
|
+
fn non_numeric_length_falls_back_to_scanning() {
|
|
461
|
+
let message = tokenize_body("35=0|95=abc|96=x|y|58=ok|");
|
|
462
|
+
assert_body_entries(
|
|
463
|
+
&message,
|
|
464
|
+
&[
|
|
465
|
+
(35, "0"),
|
|
466
|
+
(95, "abc"),
|
|
467
|
+
(96, "x"),
|
|
468
|
+
(MALFORMED_TAG, "y"),
|
|
469
|
+
(58, "ok"),
|
|
470
|
+
],
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[test]
|
|
475
|
+
fn short_length_surfaces_junk_after_the_data() {
|
|
476
|
+
let message = tokenize_body("35=0|95=1|96=a|b|58=ok|");
|
|
477
|
+
assert_body_entries(
|
|
478
|
+
&message,
|
|
479
|
+
&[
|
|
480
|
+
(35, "0"),
|
|
481
|
+
(95, "1"),
|
|
482
|
+
(96, "a"),
|
|
483
|
+
(MALFORMED_TAG, "b"),
|
|
484
|
+
(58, "ok"),
|
|
485
|
+
],
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#[test]
|
|
490
|
+
fn dialect_extra_delimits_data() {
|
|
491
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|5001=3|5002=a|b|58=ok|");
|
|
492
|
+
let message = Tokenizer::with_extra_length_tags([5001])
|
|
493
|
+
.tokenize(Bytes::from(frame))
|
|
494
|
+
.unwrap();
|
|
495
|
+
assert_tiles(&message);
|
|
496
|
+
assert_body_entries(
|
|
497
|
+
&message,
|
|
498
|
+
&[(35, "0"), (5001, "3"), (5002, "a\x01b"), (58, "ok")],
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[test]
|
|
503
|
+
fn standard_set_survives_extras() {
|
|
504
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|95=3|96=a|b|");
|
|
505
|
+
let message = Tokenizer::with_extra_length_tags([5001])
|
|
506
|
+
.tokenize(Bytes::from(frame))
|
|
507
|
+
.unwrap();
|
|
508
|
+
assert_tiles(&message);
|
|
509
|
+
assert_body_entries(&message, &[(35, "0"), (95, "3"), (96, "a\x01b")]);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn extra_below_the_standard_minimum() {
|
|
514
|
+
let frame = construct_valid_frame("FIX.4.4", "35=0|42=3|58=a|b|11=ok|");
|
|
515
|
+
let message = Tokenizer::with_extra_length_tags([42])
|
|
516
|
+
.tokenize(Bytes::from(frame))
|
|
517
|
+
.unwrap();
|
|
518
|
+
assert_tiles(&message);
|
|
519
|
+
assert_body_entries(
|
|
520
|
+
&message,
|
|
521
|
+
&[(35, "0"), (42, "3"), (58, "a\x01b"), (11, "ok")],
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#[test]
|
|
526
|
+
fn tag_zero_extra_is_ignored() {
|
|
527
|
+
let tokenizer = Tokenizer::with_extra_length_tags([0]);
|
|
528
|
+
assert!(!tokenizer.is_length_tag(MALFORMED_TAG));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
350
531
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
|
|
4
|
+
from refix.errors import GarbledReason
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RawMessage:
|
|
8
|
+
@property
|
|
9
|
+
def bytes(self) -> builtins.bytes: ...
|
|
10
|
+
|
|
11
|
+
def get(self, tag: int) -> builtins.bytes | None: ...
|
|
12
|
+
|
|
13
|
+
def entries(self) -> list[tuple[int, builtins.bytes]]: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Tokenizer:
|
|
17
|
+
def __init__(self, *, extra_length_tags: Sequence[int] = ...) -> None: ...
|
|
18
|
+
|
|
19
|
+
def tokenize(self, data: bytes) -> RawMessage: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Garble:
|
|
23
|
+
@property
|
|
24
|
+
def bytes(self) -> builtins.bytes: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def reason(self) -> GarbledReason: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MessageStream:
|
|
31
|
+
def __init__(self, *, extra_length_tags: Sequence[int] = ...) -> None: ...
|
|
32
|
+
|
|
33
|
+
def feed(self, data: bytes) -> None: ...
|
|
34
|
+
|
|
35
|
+
def next_message(self) -> RawMessage | Garble | None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
MALFORMED_TAG: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def version() -> str: ...
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Iterator, Sequence
|
|
3
|
+
from typing import BinaryIO
|
|
4
|
+
|
|
5
|
+
from refix._core import Garble, MessageStream, RawMessage
|
|
6
|
+
|
|
7
|
+
_CHUNK_SIZE = 65536
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_log(source: str | os.PathLike[str] | BinaryIO,
|
|
11
|
+
*,
|
|
12
|
+
extra_length_tags: Sequence[int] = (), ) -> Iterator[RawMessage | Garble]:
|
|
13
|
+
"""Yields each message in a FIX message log, in order.
|
|
14
|
+
|
|
15
|
+
Bytes between frames that are not FIX (timestamps, newlines, junk) are yielded as `Garble`.
|
|
16
|
+
A truncated frame at the end of the log is ignored.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(source, (str, os.PathLike)):
|
|
19
|
+
with open(source, "rb") as f:
|
|
20
|
+
yield from _drive(f, extra_length_tags)
|
|
21
|
+
else:
|
|
22
|
+
yield from _drive(source, extra_length_tags)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _drive(file: BinaryIO, extra_length_tags: Sequence[int]) -> Iterator[RawMessage | Garble]:
|
|
26
|
+
stream = MessageStream(extra_length_tags=extra_length_tags)
|
|
27
|
+
while chunk := file.read(_CHUNK_SIZE):
|
|
28
|
+
stream.feed(chunk)
|
|
29
|
+
while (outcome := stream.next_message()) is not None:
|
|
30
|
+
yield outcome
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
mod message;
|
|
2
|
+
mod stream;
|
|
2
3
|
mod tokenizer;
|
|
3
4
|
|
|
4
5
|
use pyo3::prelude::*;
|
|
@@ -11,6 +12,8 @@ fn version() -> &'static str {
|
|
|
11
12
|
#[pymodule]
|
|
12
13
|
fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
13
14
|
m.add_function(wrap_pyfunction!(version, m)?)?;
|
|
15
|
+
m.add_class::<stream::Garble>()?;
|
|
16
|
+
m.add_class::<stream::MessageStream>()?;
|
|
14
17
|
m.add_class::<message::RawMessage>()?;
|
|
15
18
|
m.add_class::<tokenizer::Tokenizer>()?;
|
|
16
19
|
m.add("MALFORMED_TAG", refix_message::MALFORMED_TAG)?;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
use crate::tokenizer::reason_str;
|
|
2
|
+
use bytes::Bytes;
|
|
3
|
+
use pyo3::types::{PyAnyMethods, PyBytes, PyModule};
|
|
4
|
+
use pyo3::{Bound, Py, PyAny, PyResult, Python, pyclass, pymethods};
|
|
5
|
+
use refix_message::framing::GarbledReason;
|
|
6
|
+
use refix_message::stream::Outcome;
|
|
7
|
+
use refix_message::{MessageStream as CoreMessageStream, Tokenizer as CoreTokenizer};
|
|
8
|
+
|
|
9
|
+
#[pyclass(module = "refix._core")]
|
|
10
|
+
pub(crate) struct MessageStream(CoreMessageStream);
|
|
11
|
+
|
|
12
|
+
#[pymethods]
|
|
13
|
+
impl MessageStream {
|
|
14
|
+
#[new]
|
|
15
|
+
#[pyo3(signature = (*, extra_length_tags = Vec::new()))]
|
|
16
|
+
fn new(extra_length_tags: Vec<u32>) -> Self {
|
|
17
|
+
let tokenizer = CoreTokenizer::with_extra_length_tags(extra_length_tags);
|
|
18
|
+
Self(CoreMessageStream::new(tokenizer))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn feed(&mut self, data: &[u8]) {
|
|
22
|
+
self.0.feed(data);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn next_message(&mut self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
|
|
26
|
+
match self.0.next_message() {
|
|
27
|
+
Outcome::Message(message) => {
|
|
28
|
+
let message = crate::message::RawMessage::new(message);
|
|
29
|
+
Ok(Some(Py::new(py, message)?.into_any()))
|
|
30
|
+
}
|
|
31
|
+
Outcome::Garbled { reason, bytes } => {
|
|
32
|
+
Ok(Some(Py::new(py, Garble { reason, bytes })?.into_any()))
|
|
33
|
+
}
|
|
34
|
+
Outcome::Incomplete => Ok(None),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Bytes between messages that cannot begin one.
|
|
40
|
+
#[pyclass(frozen, module = "refix._core")]
|
|
41
|
+
pub(crate) struct Garble {
|
|
42
|
+
reason: GarbledReason,
|
|
43
|
+
bytes: Bytes,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[pymethods]
|
|
47
|
+
impl Garble {
|
|
48
|
+
#[getter]
|
|
49
|
+
fn bytes<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> {
|
|
50
|
+
PyBytes::new(py, &self.bytes)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[getter]
|
|
54
|
+
fn reason<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
|
55
|
+
let reasons = PyModule::import(py, "refix.errors")?.getattr("GarbledReason")?;
|
|
56
|
+
reasons.call1((reason_str(self.reason),))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -14,8 +14,9 @@ pub(crate) struct Tokenizer(CoreTokenizer);
|
|
|
14
14
|
#[pymethods]
|
|
15
15
|
impl Tokenizer {
|
|
16
16
|
#[new]
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
#[pyo3(signature = (*, extra_length_tags = Vec::new()))]
|
|
18
|
+
fn new(extra_length_tags: Vec<u32>) -> Self {
|
|
19
|
+
Self(CoreTokenizer::with_extra_length_tags(extra_length_tags))
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
fn tokenize(&self, data: &[u8]) -> PyResult<crate::message::RawMessage> {
|
|
@@ -35,7 +36,7 @@ fn to_py_err(error: TokenizeError) -> PyErr {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
fn reason_str(reason: GarbledReason) -> &'static str {
|
|
39
|
+
pub(crate) fn reason_str(reason: GarbledReason) -> &'static str {
|
|
39
40
|
match reason {
|
|
40
41
|
GarbledReason::MissingBeginString => "missing_begin_string",
|
|
41
42
|
GarbledReason::MalformedBeginString => "malformed_begin_string",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Any, Callable, Protocol, TypeVar
|
|
2
|
+
|
|
3
|
+
import refix
|
|
4
|
+
from test_tokenizer import construct_valid_frame
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Benchmark(Protocol):
|
|
10
|
+
"""The callable interface of pytest-benchmark's `benchmark` fixture."""
|
|
11
|
+
|
|
12
|
+
def __call__(self, target: Callable[..., T], *args: Any) -> T: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
EXECUTION_REPORT_BODY = (
|
|
16
|
+
"35=8|34=123|49=BROKER|56=CLIENT|52=20260731-10:00:00.123|"
|
|
17
|
+
"37=ORD0001|11=CLT0001|17=EXEC0001|150=F|39=2|55=EURUSD|54=1|"
|
|
18
|
+
"38=1000000|44=1.0925|32=1000000|31=1.0925|14=1000000|151=0|"
|
|
19
|
+
"6=1.0925|60=20260731-10:00:00.120|"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def execution_report() -> bytes:
|
|
24
|
+
return construct_valid_frame("FIX.4.4", EXECUTION_REPORT_BODY)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_bench_tokenize(benchmark: Benchmark) -> None:
|
|
28
|
+
frame = execution_report()
|
|
29
|
+
tokenizer = refix.Tokenizer()
|
|
30
|
+
|
|
31
|
+
message = benchmark(tokenizer.tokenize, frame)
|
|
32
|
+
|
|
33
|
+
assert message.get(35) == b"8"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_bench_get_hit(benchmark: Benchmark) -> None:
|
|
37
|
+
message = refix.Tokenizer().tokenize(execution_report())
|
|
38
|
+
|
|
39
|
+
value = benchmark(message.get, 44)
|
|
40
|
+
|
|
41
|
+
assert value == b"1.0925"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_bench_get_miss(benchmark: Benchmark) -> None:
|
|
45
|
+
message = refix.Tokenizer().tokenize(execution_report())
|
|
46
|
+
|
|
47
|
+
value = benchmark(message.get, 9999)
|
|
48
|
+
|
|
49
|
+
assert value is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_bench_entries(benchmark: Benchmark) -> None:
|
|
53
|
+
message = refix.Tokenizer().tokenize(execution_report())
|
|
54
|
+
|
|
55
|
+
entries = benchmark(message.entries)
|
|
56
|
+
|
|
57
|
+
assert len(entries) == 23
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import refix
|
|
5
|
+
from test_tokenizer import construct_valid_frame
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def messages_of(outcomes: list[refix.RawMessage | refix.Garble]) -> list[refix.RawMessage]:
|
|
9
|
+
return [outcome for outcome in outcomes if isinstance(outcome, refix.RawMessage)]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestReadLog:
|
|
13
|
+
def test_reads_messages_from_a_path(self, tmp_path: Path):
|
|
14
|
+
first = construct_valid_frame("FIX.4.4", "35=0|58=first|")
|
|
15
|
+
second = construct_valid_frame("FIX.4.4", "35=0|58=second|")
|
|
16
|
+
path = tmp_path / "messages.log"
|
|
17
|
+
path.write_bytes(first + second)
|
|
18
|
+
|
|
19
|
+
outcomes = list(refix.read_log(path))
|
|
20
|
+
|
|
21
|
+
assert [message.bytes for message in messages_of(outcomes)] == [first, second]
|
|
22
|
+
|
|
23
|
+
def test_reads_from_a_file_object(self):
|
|
24
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|")
|
|
25
|
+
|
|
26
|
+
outcomes = list(refix.read_log(BytesIO(frame)))
|
|
27
|
+
|
|
28
|
+
assert len(outcomes) == 1
|
|
29
|
+
assert messages_of(outcomes)[0].bytes == frame
|
|
30
|
+
|
|
31
|
+
def test_timestamped_log_yields_messages_and_garbles(self):
|
|
32
|
+
frames = [
|
|
33
|
+
construct_valid_frame("FIX.4.4", "35=0|58=first|"),
|
|
34
|
+
construct_valid_frame("FIX.4.4", "35=0|58=second|"),
|
|
35
|
+
]
|
|
36
|
+
log = b"".join(
|
|
37
|
+
b"20260803-10:00:00.%03d : " % i + frame + b"\n"
|
|
38
|
+
for i, frame in enumerate(frames)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
outcomes = list(refix.read_log(BytesIO(log)))
|
|
42
|
+
|
|
43
|
+
assert [message.bytes for message in messages_of(outcomes)] == frames
|
|
44
|
+
assert b"".join(outcome.bytes for outcome in outcomes) == log
|
|
45
|
+
|
|
46
|
+
def test_truncated_tail_is_ignored(self):
|
|
47
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|58=hello|")
|
|
48
|
+
log = frame + frame[: len(frame) // 2]
|
|
49
|
+
|
|
50
|
+
outcomes = list(refix.read_log(BytesIO(log)))
|
|
51
|
+
|
|
52
|
+
assert len(outcomes) == 1
|
|
53
|
+
assert messages_of(outcomes)[0].bytes == frame
|
|
54
|
+
|
|
55
|
+
def test_extra_length_tags_flow_through(self):
|
|
56
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|5001=3|5002=a|b|")
|
|
57
|
+
|
|
58
|
+
outcomes = list(refix.read_log(BytesIO(frame), extra_length_tags=[5001]))
|
|
59
|
+
|
|
60
|
+
assert messages_of(outcomes)[0].get(5002) == b"a\x01b"
|
|
61
|
+
|
|
62
|
+
def test_log_larger_than_one_chunk(self):
|
|
63
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|58=hello|")
|
|
64
|
+
count = 5000
|
|
65
|
+
log = frame * count
|
|
66
|
+
assert len(log) > 65536, "log must span several read chunks"
|
|
67
|
+
|
|
68
|
+
outcomes = list(refix.read_log(BytesIO(log)))
|
|
69
|
+
|
|
70
|
+
assert len(outcomes) == count
|
|
71
|
+
assert all(
|
|
72
|
+
message.bytes == frame for message in messages_of(outcomes)
|
|
73
|
+
)
|
|
74
|
+
assert len(messages_of(outcomes)) == count
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import refix
|
|
2
|
+
from refix.errors import GarbledReason
|
|
3
|
+
from test_tokenizer import construct_valid_frame, to_wire
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def drain(stream: refix.MessageStream) -> list[refix.RawMessage | refix.Garble]:
|
|
7
|
+
"""Collects outcomes until the stream reports no complete message."""
|
|
8
|
+
outcomes: list[refix.RawMessage | refix.Garble] = []
|
|
9
|
+
while (outcome := stream.next_message()) is not None:
|
|
10
|
+
outcomes.append(outcome)
|
|
11
|
+
return outcomes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def expect_message(outcome: object) -> refix.RawMessage:
|
|
15
|
+
assert isinstance(outcome, refix.RawMessage)
|
|
16
|
+
return outcome
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def expect_garble(outcome: object) -> refix.Garble:
|
|
20
|
+
assert isinstance(outcome, refix.Garble)
|
|
21
|
+
return outcome
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestDelivery:
|
|
25
|
+
def test_empty_stream_has_no_message(self):
|
|
26
|
+
assert refix.MessageStream().next_message() is None
|
|
27
|
+
|
|
28
|
+
def test_single_message(self):
|
|
29
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|58=hi|")
|
|
30
|
+
stream = refix.MessageStream()
|
|
31
|
+
stream.feed(frame)
|
|
32
|
+
|
|
33
|
+
outcomes = drain(stream)
|
|
34
|
+
|
|
35
|
+
assert len(outcomes) == 1
|
|
36
|
+
assert expect_message(outcomes[0]).bytes == frame
|
|
37
|
+
|
|
38
|
+
def test_several_messages_in_one_feed(self):
|
|
39
|
+
first = construct_valid_frame("FIX.4.4", "35=0|58=first|")
|
|
40
|
+
second = construct_valid_frame("FIX.4.4", "35=0|58=second|")
|
|
41
|
+
stream = refix.MessageStream()
|
|
42
|
+
stream.feed(first)
|
|
43
|
+
stream.feed(second)
|
|
44
|
+
|
|
45
|
+
outcomes = drain(stream)
|
|
46
|
+
|
|
47
|
+
assert len(outcomes) == 2
|
|
48
|
+
assert expect_message(outcomes[0]).bytes == first
|
|
49
|
+
assert expect_message(outcomes[1]).bytes == second
|
|
50
|
+
|
|
51
|
+
def test_message_completes_across_feeds_at_every_split(self):
|
|
52
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|95=3|96=a|b|58=ok|")
|
|
53
|
+
for split in range(1, len(frame)):
|
|
54
|
+
stream = refix.MessageStream()
|
|
55
|
+
stream.feed(frame[:split])
|
|
56
|
+
assert stream.next_message() is None, f"prefix of {split} bytes"
|
|
57
|
+
|
|
58
|
+
stream.feed(frame[split:])
|
|
59
|
+
outcomes = drain(stream)
|
|
60
|
+
assert len(outcomes) == 1, f"split at {split}"
|
|
61
|
+
message = expect_message(outcomes[0])
|
|
62
|
+
assert message.bytes == frame, f"split at {split}"
|
|
63
|
+
assert message.get(96) == b"a\x01b"
|
|
64
|
+
|
|
65
|
+
def test_dialect_extras_flow_through(self):
|
|
66
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|5001=3|5002=a|b|")
|
|
67
|
+
stream = refix.MessageStream(extra_length_tags=[5001])
|
|
68
|
+
stream.feed(frame)
|
|
69
|
+
|
|
70
|
+
outcomes = drain(stream)
|
|
71
|
+
|
|
72
|
+
assert expect_message(outcomes[0]).get(5002) == b"a\x01b"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestFaults:
|
|
76
|
+
def test_garbage_between_messages(self):
|
|
77
|
+
first = construct_valid_frame("FIX.4.4", "35=0|58=first|")
|
|
78
|
+
second = construct_valid_frame("FIX.4.4", "35=0|58=second|")
|
|
79
|
+
stream = refix.MessageStream()
|
|
80
|
+
stream.feed(first)
|
|
81
|
+
stream.feed(b"YY8=Z")
|
|
82
|
+
stream.feed(second)
|
|
83
|
+
|
|
84
|
+
outcomes = drain(stream)
|
|
85
|
+
|
|
86
|
+
assert len(outcomes) == 3
|
|
87
|
+
assert expect_message(outcomes[0]).bytes == first
|
|
88
|
+
garble = expect_garble(outcomes[1])
|
|
89
|
+
assert garble.reason is GarbledReason.MISSING_BEGIN_STRING
|
|
90
|
+
assert garble.bytes == b"YY8=Z"
|
|
91
|
+
assert expect_message(outcomes[2]).bytes == second
|
|
92
|
+
|
|
93
|
+
def test_leading_garbage_is_skipped(self):
|
|
94
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|")
|
|
95
|
+
stream = refix.MessageStream()
|
|
96
|
+
stream.feed(b"junk")
|
|
97
|
+
stream.feed(frame)
|
|
98
|
+
|
|
99
|
+
outcomes = drain(stream)
|
|
100
|
+
|
|
101
|
+
assert len(outcomes) == 2
|
|
102
|
+
garble = expect_garble(outcomes[0])
|
|
103
|
+
assert garble.reason is GarbledReason.MISSING_BEGIN_STRING
|
|
104
|
+
assert garble.bytes == b"junk"
|
|
105
|
+
assert expect_message(outcomes[1]).bytes == frame
|
|
106
|
+
|
|
107
|
+
def test_garbled_frame_does_not_stall_the_stream(self):
|
|
108
|
+
bad = bytearray(construct_valid_frame("FIX.4.4", "35=0|58=bad|"))
|
|
109
|
+
bad[-2] = ord("1") if bad[-2] == ord("0") else ord("0")
|
|
110
|
+
good = construct_valid_frame("FIX.4.4", "35=0|58=good|")
|
|
111
|
+
stream = refix.MessageStream()
|
|
112
|
+
stream.feed(bytes(bad))
|
|
113
|
+
stream.feed(good)
|
|
114
|
+
|
|
115
|
+
outcomes = drain(stream)
|
|
116
|
+
|
|
117
|
+
assert expect_garble(outcomes[0]).reason is GarbledReason.CHECKSUM_MISMATCH
|
|
118
|
+
assert expect_message(outcomes[-1]).bytes == good
|
|
119
|
+
|
|
120
|
+
def test_oversized_frame_is_skipped_not_fatal(self):
|
|
121
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|")
|
|
122
|
+
stream = refix.MessageStream()
|
|
123
|
+
stream.feed(to_wire("8=FIX.4.4|9=1048577|"))
|
|
124
|
+
stream.feed(frame)
|
|
125
|
+
|
|
126
|
+
outcomes = drain(stream)
|
|
127
|
+
|
|
128
|
+
assert expect_garble(outcomes[0]).reason is GarbledReason.FRAME_TOO_LARGE
|
|
129
|
+
assert expect_message(outcomes[-1]).bytes == frame
|
|
@@ -111,6 +111,7 @@ class TestErrors:
|
|
|
111
111
|
refix.Tokenizer().tokenize(frame + frame)
|
|
112
112
|
|
|
113
113
|
assert excinfo.value.frame_len == len(frame)
|
|
114
|
+
assert str(excinfo.value) == f"one message of {len(frame)} bytes, then trailing input"
|
|
114
115
|
|
|
115
116
|
|
|
116
117
|
class TestGarbledFrames:
|
|
@@ -136,6 +137,7 @@ class TestGarbledFrames:
|
|
|
136
137
|
refix.Tokenizer().tokenize(to_wire(frame))
|
|
137
138
|
|
|
138
139
|
assert excinfo.value.reason is reason
|
|
140
|
+
assert str(excinfo.value) == f"garbled frame: {reason.value}"
|
|
139
141
|
|
|
140
142
|
def test_garbled_error_pickles(self):
|
|
141
143
|
with pytest.raises(GarbledError) as excinfo:
|
|
@@ -184,3 +186,35 @@ class TestSentinelRuns:
|
|
|
184
186
|
(refix.MALFORMED_TAG, b"more"),
|
|
185
187
|
(58, b"ok"),
|
|
186
188
|
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestLengthTags:
|
|
192
|
+
"""Length-delimited data fields, whose values may contain SOH."""
|
|
193
|
+
|
|
194
|
+
def test_standard_length_tag_delimits_data(self):
|
|
195
|
+
message = tokenize_body("35=0|95=3|96=a|b|58=ok|")
|
|
196
|
+
assert body_entries(message) == [
|
|
197
|
+
(35, b"0"),
|
|
198
|
+
(95, b"3"),
|
|
199
|
+
(96, b"a\x01b"),
|
|
200
|
+
(58, b"ok"),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def test_dialect_extra_delimits_data(self):
|
|
204
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|5001=3|5002=a|b|58=ok|")
|
|
205
|
+
|
|
206
|
+
message = refix.Tokenizer(extra_length_tags=[5001]).tokenize(frame)
|
|
207
|
+
|
|
208
|
+
assert body_entries(message) == [
|
|
209
|
+
(35, b"0"),
|
|
210
|
+
(5001, b"3"),
|
|
211
|
+
(5002, b"a\x01b"),
|
|
212
|
+
(58, b"ok"),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def test_standard_set_survives_extras(self):
|
|
216
|
+
frame = construct_valid_frame("FIX.4.4", "35=0|95=3|96=a|b|")
|
|
217
|
+
|
|
218
|
+
message = refix.Tokenizer(extra_length_tags=[5001]).tokenize(frame)
|
|
219
|
+
|
|
220
|
+
assert body_entries(message) == [(35, b"0"), (95, b"3"), (96, b"a\x01b")]
|
|
@@ -137,6 +137,15 @@ wheels = [
|
|
|
137
137
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
138
138
|
]
|
|
139
139
|
|
|
140
|
+
[[package]]
|
|
141
|
+
name = "py-cpuinfo"
|
|
142
|
+
version = "9.0.0"
|
|
143
|
+
source = { registry = "https://pypi.org/simple" }
|
|
144
|
+
sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
|
|
145
|
+
wheels = [
|
|
146
|
+
{ url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
|
|
147
|
+
]
|
|
148
|
+
|
|
140
149
|
[[package]]
|
|
141
150
|
name = "pygments"
|
|
142
151
|
version = "2.20.0"
|
|
@@ -175,6 +184,19 @@ wheels = [
|
|
|
175
184
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
176
185
|
]
|
|
177
186
|
|
|
187
|
+
[[package]]
|
|
188
|
+
name = "pytest-benchmark"
|
|
189
|
+
version = "5.2.3"
|
|
190
|
+
source = { registry = "https://pypi.org/simple" }
|
|
191
|
+
dependencies = [
|
|
192
|
+
{ name = "py-cpuinfo" },
|
|
193
|
+
{ name = "pytest" },
|
|
194
|
+
]
|
|
195
|
+
sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" }
|
|
196
|
+
wheels = [
|
|
197
|
+
{ url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" },
|
|
198
|
+
]
|
|
199
|
+
|
|
178
200
|
[[package]]
|
|
179
201
|
name = "pytest-cov"
|
|
180
202
|
version = "7.1.0"
|
|
@@ -198,6 +220,7 @@ dev = [
|
|
|
198
220
|
{ name = "maturin" },
|
|
199
221
|
{ name = "pyright" },
|
|
200
222
|
{ name = "pytest" },
|
|
223
|
+
{ name = "pytest-benchmark" },
|
|
201
224
|
{ name = "pytest-cov" },
|
|
202
225
|
]
|
|
203
226
|
|
|
@@ -208,6 +231,7 @@ dev = [
|
|
|
208
231
|
{ name = "maturin", specifier = ">=1.9.4,<2" },
|
|
209
232
|
{ name = "pyright", specifier = ">=1.1" },
|
|
210
233
|
{ name = "pytest", specifier = ">=8" },
|
|
234
|
+
{ name = "pytest-benchmark", specifier = ">=5" },
|
|
211
235
|
{ name = "pytest-cov", specifier = ">=6" },
|
|
212
236
|
]
|
|
213
237
|
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import builtins
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class RawMessage:
|
|
5
|
-
@property
|
|
6
|
-
def bytes(self) -> builtins.bytes: ...
|
|
7
|
-
|
|
8
|
-
def get(self, tag: int) -> builtins.bytes | None: ...
|
|
9
|
-
|
|
10
|
-
def entries(self) -> list[tuple[int, builtins.bytes]]: ...
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class Tokenizer:
|
|
14
|
-
def tokenize(self, data: bytes) -> RawMessage: ...
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
MALFORMED_TAG: int
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def version() -> str: ...
|
|
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
|