fixcore-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fixcore/__init__.py +6 -0
- fixcore/application.py +47 -0
- fixcore/log/__init__.py +7 -0
- fixcore/log/base.py +27 -0
- fixcore/log/factory.py +10 -0
- fixcore/log/file_log.py +70 -0
- fixcore/log/screen.py +32 -0
- fixcore/message/__init__.py +17 -0
- fixcore/message/cracker.py +243 -0
- fixcore/message/data_dictionary.py +298 -0
- fixcore/message/exceptions.py +21 -0
- fixcore/message/field.py +147 -0
- fixcore/message/message.py +403 -0
- fixcore/session/__init__.py +8 -0
- fixcore/session/session.py +532 -0
- fixcore/session/session_id.py +32 -0
- fixcore/session/session_settings.py +146 -0
- fixcore/session/state.py +60 -0
- fixcore/store/__init__.py +11 -0
- fixcore/store/base.py +49 -0
- fixcore/store/factory.py +33 -0
- fixcore/store/file_store.py +162 -0
- fixcore/store/memory.py +50 -0
- fixcore/transport/__init__.py +7 -0
- fixcore/transport/acceptor.py +166 -0
- fixcore/transport/framer.py +107 -0
- fixcore/transport/initiator.py +146 -0
- fixcore_engine-0.1.0.dist-info/METADATA +75 -0
- fixcore_engine-0.1.0.dist-info/RECORD +32 -0
- fixcore_engine-0.1.0.dist-info/WHEEL +5 -0
- fixcore_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixcore_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""DataDictionary — loads a QuickFIX XML spec and validates FIX messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fixcore.message.exceptions import InvalidMessage
|
|
10
|
+
|
|
11
|
+
# Imported here to avoid circular import at module level; also used as constants
|
|
12
|
+
TAG_BODY_LENGTH = 9
|
|
13
|
+
TAG_CHECKSUM = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Data model
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FieldDef:
|
|
22
|
+
"""Definition of a single FIX field."""
|
|
23
|
+
number: int
|
|
24
|
+
name: str
|
|
25
|
+
type: str # STRING, INT, PRICE, QTY, CHAR, …
|
|
26
|
+
values: dict[str, str] = field(default_factory=dict) # enum → description
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class GroupDef:
|
|
31
|
+
"""Definition of a repeating group."""
|
|
32
|
+
number_tag: int # NoXxx counter tag (e.g. 78 NoAllocs)
|
|
33
|
+
delimiter: int # first expected tag inside each instance
|
|
34
|
+
members: list[int] # ordered member tags
|
|
35
|
+
nested_groups: dict[int, "GroupDef"] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MessageDef:
|
|
40
|
+
"""Definition of a FIX message type."""
|
|
41
|
+
name: str
|
|
42
|
+
msg_type: str # e.g. "D", "0", "A"
|
|
43
|
+
msg_cat: str # "admin" | "app"
|
|
44
|
+
required: set[int] = field(default_factory=set)
|
|
45
|
+
optional: set[int] = field(default_factory=set)
|
|
46
|
+
groups: dict[int, GroupDef] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def all_fields(self) -> set[int]:
|
|
50
|
+
return self.required | self.optional
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# DataDictionary
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
class DataDictionary:
|
|
58
|
+
"""Parses a QuickFIX-format XML spec and validates Message objects.
|
|
59
|
+
|
|
60
|
+
Usage::
|
|
61
|
+
|
|
62
|
+
dd = DataDictionary.from_xml("specs/FIX42.xml")
|
|
63
|
+
dd.validate(message) # raises InvalidMessage on failure
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._version: str = ""
|
|
68
|
+
self._fields_by_number: dict[int, FieldDef] = {}
|
|
69
|
+
self._fields_by_name: dict[str, int] = {} # name → tag number
|
|
70
|
+
self._messages: dict[str, MessageDef] = {} # msg_type → MessageDef
|
|
71
|
+
self._header: dict[int, bool] = {} # tag → required
|
|
72
|
+
self._trailer: dict[int, bool] = {}
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Construction
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_xml(cls, path: str | Path) -> "DataDictionary":
|
|
80
|
+
"""Load a DataDictionary from a QuickFIX XML spec file."""
|
|
81
|
+
tree = ET.parse(str(path))
|
|
82
|
+
root = tree.getroot()
|
|
83
|
+
dd = cls()
|
|
84
|
+
dd._version = _build_version(root)
|
|
85
|
+
dd._load_fields(root)
|
|
86
|
+
dd._load_header(root)
|
|
87
|
+
dd._load_trailer(root)
|
|
88
|
+
dd._load_messages(root)
|
|
89
|
+
return dd
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_string(cls, xml_text: str) -> "DataDictionary":
|
|
93
|
+
"""Load a DataDictionary from an XML string (useful in tests)."""
|
|
94
|
+
root = ET.fromstring(xml_text)
|
|
95
|
+
dd = cls()
|
|
96
|
+
dd._version = _build_version(root)
|
|
97
|
+
dd._load_fields(root)
|
|
98
|
+
dd._load_header(root)
|
|
99
|
+
dd._load_trailer(root)
|
|
100
|
+
dd._load_messages(root)
|
|
101
|
+
return dd
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Internal loaders
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _load_fields(self, root: ET.Element) -> None:
|
|
108
|
+
fields_el = root.find("fields")
|
|
109
|
+
if fields_el is None:
|
|
110
|
+
return
|
|
111
|
+
for f in fields_el.findall("field"):
|
|
112
|
+
number = int(f.get("number", 0))
|
|
113
|
+
name = f.get("name", "")
|
|
114
|
+
ftype = f.get("type", "STRING").upper()
|
|
115
|
+
values = {
|
|
116
|
+
v.get("enum", ""): v.get("description", "")
|
|
117
|
+
for v in f.findall("value")
|
|
118
|
+
}
|
|
119
|
+
fd = FieldDef(number=number, name=name, type=ftype, values=values)
|
|
120
|
+
self._fields_by_number[number] = fd
|
|
121
|
+
self._fields_by_name[name] = number
|
|
122
|
+
|
|
123
|
+
def _load_header(self, root: ET.Element) -> None:
|
|
124
|
+
header_el = root.find("header")
|
|
125
|
+
if header_el is None:
|
|
126
|
+
return
|
|
127
|
+
for f in header_el.findall("field"):
|
|
128
|
+
tag = self._resolve_tag(f.get("name", ""))
|
|
129
|
+
if tag is not None:
|
|
130
|
+
self._header[tag] = f.get("required", "N").upper() == "Y"
|
|
131
|
+
|
|
132
|
+
def _load_trailer(self, root: ET.Element) -> None:
|
|
133
|
+
trailer_el = root.find("trailer")
|
|
134
|
+
if trailer_el is None:
|
|
135
|
+
return
|
|
136
|
+
for f in trailer_el.findall("field"):
|
|
137
|
+
tag = self._resolve_tag(f.get("name", ""))
|
|
138
|
+
if tag is not None:
|
|
139
|
+
self._trailer[tag] = f.get("required", "N").upper() == "Y"
|
|
140
|
+
|
|
141
|
+
def _load_messages(self, root: ET.Element) -> None:
|
|
142
|
+
messages_el = root.find("messages")
|
|
143
|
+
if messages_el is None:
|
|
144
|
+
return
|
|
145
|
+
for msg_el in messages_el.findall("message"):
|
|
146
|
+
msg_type = msg_el.get("msgtype", "")
|
|
147
|
+
name = msg_el.get("name", "")
|
|
148
|
+
cat = msg_el.get("msgcat", "app").lower()
|
|
149
|
+
msg_def = MessageDef(name=name, msg_type=msg_type, msg_cat=cat)
|
|
150
|
+
self._parse_fields_and_groups(msg_el, msg_def.required, msg_def.optional, msg_def.groups)
|
|
151
|
+
self._messages[msg_type] = msg_def
|
|
152
|
+
|
|
153
|
+
def _parse_fields_and_groups(
|
|
154
|
+
self,
|
|
155
|
+
parent: ET.Element,
|
|
156
|
+
required: set[int],
|
|
157
|
+
optional: set[int],
|
|
158
|
+
groups: dict[int, GroupDef],
|
|
159
|
+
) -> None:
|
|
160
|
+
for child in parent:
|
|
161
|
+
if child.tag == "field":
|
|
162
|
+
tag = self._resolve_tag(child.get("name", ""))
|
|
163
|
+
if tag is None:
|
|
164
|
+
continue
|
|
165
|
+
if child.get("required", "N").upper() == "Y":
|
|
166
|
+
required.add(tag)
|
|
167
|
+
else:
|
|
168
|
+
optional.add(tag)
|
|
169
|
+
elif child.tag == "group":
|
|
170
|
+
tag = self._resolve_tag(child.get("name", ""))
|
|
171
|
+
if tag is None:
|
|
172
|
+
continue
|
|
173
|
+
req = child.get("required", "N").upper() == "Y"
|
|
174
|
+
if req:
|
|
175
|
+
required.add(tag)
|
|
176
|
+
else:
|
|
177
|
+
optional.add(tag)
|
|
178
|
+
group_def = self._parse_group(tag, child)
|
|
179
|
+
groups[tag] = group_def
|
|
180
|
+
|
|
181
|
+
def _parse_group(self, number_tag: int, group_el: ET.Element) -> GroupDef:
|
|
182
|
+
members: list[int] = []
|
|
183
|
+
nested: dict[int, GroupDef] = {}
|
|
184
|
+
delimiter: int = 0
|
|
185
|
+
for child in group_el:
|
|
186
|
+
if child.tag == "field":
|
|
187
|
+
tag = self._resolve_tag(child.get("name", ""))
|
|
188
|
+
if tag is not None:
|
|
189
|
+
if delimiter == 0:
|
|
190
|
+
delimiter = tag
|
|
191
|
+
members.append(tag)
|
|
192
|
+
elif child.tag == "group":
|
|
193
|
+
tag = self._resolve_tag(child.get("name", ""))
|
|
194
|
+
if tag is not None:
|
|
195
|
+
if delimiter == 0:
|
|
196
|
+
delimiter = tag
|
|
197
|
+
members.append(tag)
|
|
198
|
+
nested[tag] = self._parse_group(tag, child)
|
|
199
|
+
return GroupDef(number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested)
|
|
200
|
+
|
|
201
|
+
def _resolve_tag(self, name: str) -> int | None:
|
|
202
|
+
return self._fields_by_name.get(name)
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Public API
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def version(self) -> str:
|
|
210
|
+
return self._version
|
|
211
|
+
|
|
212
|
+
def field_def(self, tag: int) -> FieldDef:
|
|
213
|
+
"""Return FieldDef for *tag*; raises KeyError if unknown."""
|
|
214
|
+
return self._fields_by_number[tag]
|
|
215
|
+
|
|
216
|
+
def field_tag(self, name: str) -> int:
|
|
217
|
+
"""Return the tag number for field *name*; raises KeyError if unknown."""
|
|
218
|
+
return self._fields_by_name[name]
|
|
219
|
+
|
|
220
|
+
def message_def(self, msg_type: str) -> MessageDef:
|
|
221
|
+
"""Return MessageDef for *msg_type*; raises KeyError if unknown."""
|
|
222
|
+
return self._messages[msg_type]
|
|
223
|
+
|
|
224
|
+
def is_header_field(self, tag: int) -> bool:
|
|
225
|
+
return tag in self._header
|
|
226
|
+
|
|
227
|
+
def is_trailer_field(self, tag: int) -> bool:
|
|
228
|
+
return tag in self._trailer
|
|
229
|
+
|
|
230
|
+
def validate(self, message: "Message") -> None: # type: ignore[name-defined]
|
|
231
|
+
"""Validate *message* against this DataDictionary.
|
|
232
|
+
|
|
233
|
+
Checks:
|
|
234
|
+
- MsgType is known
|
|
235
|
+
- All required header fields are present
|
|
236
|
+
- All required body fields are present
|
|
237
|
+
- All required trailer fields are present
|
|
238
|
+
|
|
239
|
+
Raises :exc:`InvalidMessage` on the first violation found.
|
|
240
|
+
"""
|
|
241
|
+
from fixcore.message.message import Message # local to avoid circular
|
|
242
|
+
|
|
243
|
+
msg_type = message.msg_type
|
|
244
|
+
if not msg_type:
|
|
245
|
+
raise InvalidMessage("Missing MsgType (tag 35)")
|
|
246
|
+
|
|
247
|
+
msg_def = self._messages.get(msg_type)
|
|
248
|
+
if msg_def is None:
|
|
249
|
+
raise InvalidMessage(f"Unknown MsgType: {msg_type!r}")
|
|
250
|
+
|
|
251
|
+
# BodyLength and CheckSum are computed by the engine on encode — skip them
|
|
252
|
+
COMPUTED_TAGS = {TAG_BODY_LENGTH, TAG_CHECKSUM}
|
|
253
|
+
|
|
254
|
+
# Required header fields
|
|
255
|
+
for tag, required in self._header.items():
|
|
256
|
+
if tag in COMPUTED_TAGS:
|
|
257
|
+
continue
|
|
258
|
+
if required and not message.header.has(tag):
|
|
259
|
+
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
260
|
+
raise InvalidMessage(f"Missing required header field: {name} ({tag})")
|
|
261
|
+
|
|
262
|
+
# Required body fields
|
|
263
|
+
for tag in msg_def.required:
|
|
264
|
+
if not message.has_field(tag):
|
|
265
|
+
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
266
|
+
raise InvalidMessage(
|
|
267
|
+
f"Missing required field: {name} ({tag}) in {msg_def.name}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Required trailer fields
|
|
271
|
+
for tag, required in self._trailer.items():
|
|
272
|
+
if tag in COMPUTED_TAGS:
|
|
273
|
+
continue
|
|
274
|
+
if required and not message.trailer.has(tag):
|
|
275
|
+
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
276
|
+
raise InvalidMessage(f"Missing required trailer field: {name} ({tag})")
|
|
277
|
+
|
|
278
|
+
def validate_field_value(self, tag: int, value: str) -> bool:
|
|
279
|
+
"""Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
|
|
280
|
+
fd = self._fields_by_number.get(tag)
|
|
281
|
+
if fd is None or not fd.values:
|
|
282
|
+
return True
|
|
283
|
+
return value in fd.values
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Helpers
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _build_version(root: ET.Element) -> str:
|
|
291
|
+
fix_type = root.get("type", "FIX")
|
|
292
|
+
major = root.get("major", "4")
|
|
293
|
+
minor = root.get("minor", "2")
|
|
294
|
+
sp = root.get("servicepack", "")
|
|
295
|
+
version = f"{fix_type}.{major}.{minor}"
|
|
296
|
+
if sp and sp != "0":
|
|
297
|
+
version += f"SP{sp}"
|
|
298
|
+
return version
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""FIX message exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidMessage(Exception):
|
|
5
|
+
"""Raised when a FIX message fails DataDictionary validation."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedVersion(Exception):
|
|
9
|
+
"""Raised when a message's BeginString has no matching DataDictionary."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FieldNotFound(KeyError):
|
|
13
|
+
"""Raised when a required field is absent from a message."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedMessageType(Exception):
|
|
17
|
+
"""Raised by MessageCracker.on_message when no handler is registered for a MsgType."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, msg_type: str) -> None:
|
|
20
|
+
super().__init__(f"No handler for MsgType {msg_type!r}")
|
|
21
|
+
self.msg_type = msg_type
|
fixcore/message/field.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""FIX field and field-map primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Field:
|
|
10
|
+
"""A single tag=value pair."""
|
|
11
|
+
|
|
12
|
+
__slots__ = ("tag", "value")
|
|
13
|
+
|
|
14
|
+
def __init__(self, tag: int, value: str) -> None:
|
|
15
|
+
self.tag = tag
|
|
16
|
+
self.value = value
|
|
17
|
+
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return f"Field({self.tag}={self.value!r})"
|
|
20
|
+
|
|
21
|
+
def __eq__(self, other: object) -> bool:
|
|
22
|
+
if not isinstance(other, Field):
|
|
23
|
+
return NotImplemented
|
|
24
|
+
return self.tag == other.tag and self.value == other.value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FieldMap:
|
|
28
|
+
"""Ordered collection of FIX fields (tag → value).
|
|
29
|
+
|
|
30
|
+
Preserves insertion order. Repeated tags are allowed (e.g. repeating
|
|
31
|
+
groups) and stored in order of insertion.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
# tag → list[str] to support repeating tags
|
|
36
|
+
self._fields: OrderedDict[int, list[str]] = OrderedDict()
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Mutation
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def set_field(self, tag: int, value: str) -> None:
|
|
43
|
+
"""Set *tag* to *value*, replacing any existing value(s)."""
|
|
44
|
+
self._fields[tag] = [value]
|
|
45
|
+
|
|
46
|
+
def append_field(self, tag: int, value: str) -> None:
|
|
47
|
+
"""Append *value* for *tag* (used for repeating groups)."""
|
|
48
|
+
if tag in self._fields:
|
|
49
|
+
self._fields[tag].append(value)
|
|
50
|
+
else:
|
|
51
|
+
self._fields[tag] = [value]
|
|
52
|
+
|
|
53
|
+
def remove_field(self, tag: int) -> None:
|
|
54
|
+
self._fields.pop(tag, None)
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# Access
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def get_field(self, tag: int) -> str:
|
|
61
|
+
"""Return the first value for *tag*; raises KeyError if absent."""
|
|
62
|
+
return self._fields[tag][0]
|
|
63
|
+
|
|
64
|
+
def get_field_or(self, tag: int, default: str = "") -> str:
|
|
65
|
+
vals = self._fields.get(tag)
|
|
66
|
+
return vals[0] if vals else default
|
|
67
|
+
|
|
68
|
+
def has_field(self, tag: int) -> bool:
|
|
69
|
+
return tag in self._fields
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Iteration
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def __iter__(self) -> Iterator[Field]:
|
|
76
|
+
"""Yield all fields in insertion order, expanding repeated tags."""
|
|
77
|
+
for tag, values in self._fields.items():
|
|
78
|
+
for value in values:
|
|
79
|
+
yield Field(tag, value)
|
|
80
|
+
|
|
81
|
+
def __len__(self) -> int:
|
|
82
|
+
return sum(len(v) for v in self._fields.values())
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
pairs = ", ".join(f"{t}={v!r}" for t, vs in self._fields.items() for v in vs)
|
|
86
|
+
return f"{self.__class__.__name__}({pairs})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Group:
|
|
90
|
+
"""One repeating group instance.
|
|
91
|
+
|
|
92
|
+
Holds the member fields for a single row within a FIX repeating group
|
|
93
|
+
(e.g. one entry in ``NoMsgTypes`` or one leg in ``NoLegs``).
|
|
94
|
+
|
|
95
|
+
Usage::
|
|
96
|
+
|
|
97
|
+
g = Group()
|
|
98
|
+
g.set_field(372, "D") # RefMsgType
|
|
99
|
+
g.set_field(385, "S") # MsgDirection
|
|
100
|
+
msg.add_group(384, g) # NoMsgTypes count tag
|
|
101
|
+
|
|
102
|
+
Nested groups are supported via :meth:`add_group` / :meth:`get_groups`.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
self._fields: dict[int, str] = {}
|
|
107
|
+
# Insertion order for both simple fields and nested-group count tags
|
|
108
|
+
self._order: list[int] = []
|
|
109
|
+
self._groups: dict[int, list["Group"]] = {}
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Simple field access (mirrors Message body API)
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def set_field(self, tag: int, value: str) -> None:
|
|
116
|
+
if tag not in self._fields:
|
|
117
|
+
self._order.append(tag)
|
|
118
|
+
self._fields[tag] = value
|
|
119
|
+
|
|
120
|
+
def get_field(self, tag: int) -> str:
|
|
121
|
+
"""Return the value for *tag*; raises KeyError if absent."""
|
|
122
|
+
return self._fields[tag]
|
|
123
|
+
|
|
124
|
+
def get_field_or(self, tag: int, default: str = "") -> str:
|
|
125
|
+
return self._fields.get(tag, default)
|
|
126
|
+
|
|
127
|
+
def has_field(self, tag: int) -> bool:
|
|
128
|
+
return tag in self._fields
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Nested groups
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def add_group(self, count_tag: int, instance: "Group") -> None:
|
|
135
|
+
"""Append a nested group instance and update the count field."""
|
|
136
|
+
if count_tag not in self._groups:
|
|
137
|
+
self._order.append(count_tag)
|
|
138
|
+
self._groups[count_tag] = []
|
|
139
|
+
self._groups[count_tag].append(instance)
|
|
140
|
+
self._fields[count_tag] = str(len(self._groups[count_tag]))
|
|
141
|
+
|
|
142
|
+
def get_groups(self, count_tag: int) -> "list[Group]":
|
|
143
|
+
return self._groups.get(count_tag, [])
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
pairs = ", ".join(f"{t}={v!r}" for t, v in self._fields.items())
|
|
147
|
+
return f"Group({pairs})"
|