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.
Files changed (39) hide show
  1. {refix_engine-0.0.1 → refix_engine-0.1.0}/Cargo.lock +3 -3
  2. {refix_engine-0.0.1 → refix_engine-0.1.0}/Cargo.toml +1 -1
  3. {refix_engine-0.0.1 → refix_engine-0.1.0}/PKG-INFO +1 -1
  4. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/Cargo.toml +1 -1
  5. refix_engine-0.1.0/crates/refix-message/src/length_tags.rs +25 -0
  6. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/lib.rs +3 -0
  7. refix_engine-0.1.0/crates/refix-message/src/stream.rs +229 -0
  8. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/tokenizer.rs +216 -35
  9. {refix_engine-0.0.1 → refix_engine-0.1.0}/pyproject.toml +1 -0
  10. refix_engine-0.1.0/python/refix/__init__.py +6 -0
  11. refix_engine-0.1.0/python/refix/_core.pyi +41 -0
  12. refix_engine-0.1.0/python/refix/log.py +30 -0
  13. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/Cargo.toml +1 -1
  14. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/src/lib.rs +3 -0
  15. refix_engine-0.1.0/python/refix-engine/src/stream.rs +58 -0
  16. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/src/tokenizer.rs +4 -3
  17. refix_engine-0.1.0/python/refix-engine/tests/test_benchmarks.py +57 -0
  18. refix_engine-0.1.0/python/refix-engine/tests/test_log.py +74 -0
  19. refix_engine-0.1.0/python/refix-engine/tests/test_stream.py +129 -0
  20. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/tests/test_tokenizer.py +34 -0
  21. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/uv.lock +24 -0
  22. refix_engine-0.0.1/python/refix/__init__.py +0 -5
  23. refix_engine-0.0.1/python/refix/_core.pyi +0 -20
  24. {refix_engine-0.0.1 → refix_engine-0.1.0}/LICENSE-APACHE +0 -0
  25. {refix_engine-0.0.1 → refix_engine-0.1.0}/LICENSE-MIT +0 -0
  26. {refix_engine-0.0.1 → refix_engine-0.1.0}/README.md +0 -0
  27. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/LICENSE-APACHE +0 -0
  28. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/LICENSE-MIT +0 -0
  29. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/README.md +0 -0
  30. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/framing.rs +0 -0
  31. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/message.rs +0 -0
  32. {refix_engine-0.0.1 → refix_engine-0.1.0}/crates/refix-message/src/test_utils.rs +0 -0
  33. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix/errors.py +0 -0
  34. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix/py.typed +0 -0
  35. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/.python-version +0 -0
  36. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/LICENSE-APACHE +0 -0
  37. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/LICENSE-MIT +0 -0
  38. {refix_engine-0.0.1 → refix_engine-0.1.0}/python/refix-engine/README.md +0 -0
  39. {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.2"
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.1"
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.1.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.1.0", path = "crates/refix-message" }
18
+ refix-message = { version = "0.2.0", path = "crates/refix-message" }
19
19
  serde = "1.0.229"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refix-engine
3
- Version: 0.0.1
3
+ Version: 0.1.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Financial and Insurance Industry
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "refix-message"
3
3
  description = "Framing, tokenisation and raw message representation for the ReFIX engine"
4
- version = "0.1.0"
4
+ version = "0.2.0"
5
5
  authors.workspace = true
6
6
  edition.workspace = true
7
7
  license.workspace = true
@@ -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
- #[derive(Default)]
7
- pub struct Tokenizer;
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
- /// Issues that can arise when the bytes handed to the tokeniser are not a valid FIX message.
10
- #[derive(Clone, Copy, Debug, PartialEq)]
11
- pub enum TokenizeError {
12
- /// The bytes fail the frame-level checks.
13
- Garbled(GarbledReason),
14
- /// The bytes end mid-message.
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
- // TODO: decide what capacity to start with
33
- let mut fields = Vec::new();
34
- let mut pos = 0;
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
- while let Some((field, next)) = next_field(bytes, pos) {
37
- fields.push(field);
38
- pos = next;
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
- fields
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: (delimiter_pos + 1) as u32,
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.tokenize(Bytes::from(bytes)).unwrap_err();
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.tokenize(Bytes::from(bytes)).unwrap_err();
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.tokenize(Bytes::from(bytes)).unwrap_err();
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
  }
@@ -37,6 +37,7 @@ dev = [
37
37
  "maturin>=1.9.4,<2",
38
38
  "pyright>=1.1",
39
39
  "pytest>=8",
40
+ "pytest-benchmark>=5",
40
41
  "pytest-cov>=6",
41
42
  ]
42
43
 
@@ -0,0 +1,6 @@
1
+ from refix._core import MALFORMED_TAG, Garble, MessageStream, RawMessage, Tokenizer, version
2
+ from refix.log import read_log
3
+
4
+ __version__ = version()
5
+
6
+ __all__ = ["MALFORMED_TAG", "Garble", "MessageStream", "RawMessage", "read_log", "Tokenizer", "__version__"]
@@ -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,6 +1,6 @@
1
1
  [package]
2
2
  name = "refix-engine"
3
- version = "0.0.1"
3
+ version = "0.1.0"
4
4
  publish = false
5
5
  authors.workspace = true
6
6
  edition.workspace = true
@@ -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
- fn new() -> Self {
18
- Self(CoreTokenizer)
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,5 +0,0 @@
1
- from refix._core import MALFORMED_TAG, RawMessage, Tokenizer, version
2
-
3
- __version__ = version()
4
-
5
- __all__ = ["MALFORMED_TAG", "RawMessage", "Tokenizer", "__version__"]
@@ -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