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.
@@ -0,0 +1,532 @@
1
+ """FIX Session — state machine, sequence number management, admin message handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Awaitable, Callable
9
+
10
+ from fixcore.application import Application
11
+ from fixcore.log.base import Log
12
+ from fixcore.message.message import Message
13
+ from fixcore.session.session_id import SessionID
14
+ from fixcore.session.session_settings import SessionSettings
15
+ from fixcore.session.state import (
16
+ ADMIN_MSG_TYPES,
17
+ MSG_HEARTBEAT,
18
+ MSG_LOGON,
19
+ MSG_LOGOUT,
20
+ MSG_REJECT,
21
+ MSG_RESEND_REQUEST,
22
+ MSG_SEQUENCE_RESET,
23
+ MSG_TEST_REQUEST,
24
+ TAG_BEGIN_SEQ_NO,
25
+ TAG_BEGIN_STRING,
26
+ TAG_ENCRYPT_METHOD,
27
+ TAG_END_SEQ_NO,
28
+ TAG_GAP_FILL_FLAG,
29
+ TAG_HEART_BT_INT,
30
+ TAG_MSG_SEQ_NUM,
31
+ TAG_MSG_TYPE,
32
+ TAG_NEW_SEQ_NO,
33
+ TAG_ORIG_SENDING_TIME,
34
+ TAG_POSS_DUP_FLAG,
35
+ TAG_REF_MSG_TYPE,
36
+ TAG_REF_SEQ_NUM,
37
+ TAG_REF_TAG_ID,
38
+ TAG_RESET_SEQ_NUM_FLAG,
39
+ TAG_SENDER_COMP_ID,
40
+ TAG_SENDING_TIME,
41
+ TAG_SESSION_REJECT_REASON,
42
+ TAG_TARGET_COMP_ID,
43
+ TAG_TEST_REQ_ID,
44
+ TAG_TEXT,
45
+ SessionState,
46
+ )
47
+ from fixcore.store.base import MessageStore
48
+
49
+
50
+ def _utc_timestamp() -> str:
51
+ return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S")
52
+
53
+
54
+ # Type alias for the transport-provided send callback
55
+ SendFn = Callable[[bytes], Awaitable[None]]
56
+
57
+
58
+ class Session:
59
+ """Manages a single FIX session's full lifecycle.
60
+
61
+ The transport layer calls:
62
+ - ``on_connect(send_fn)`` — when TCP connection is established
63
+ - ``on_disconnect()`` — when connection is lost
64
+ - ``on_data(raw)`` — with each complete, framed FIX message
65
+
66
+ User code calls:
67
+ - ``send_app(message)`` — to send an application message
68
+ - ``send_logout(text)`` — to initiate a graceful logout
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ session_id: SessionID,
74
+ settings: SessionSettings,
75
+ application: Application,
76
+ store: MessageStore,
77
+ log: Log,
78
+ ) -> None:
79
+ self._id = session_id
80
+ self._settings = settings
81
+ self._app = application
82
+ self._store = store
83
+ self._log = log
84
+
85
+ # Config (with defaults)
86
+ self._heartbt_int: int = settings.get_int(session_id, "HeartBtInt", 30)
87
+ self._logon_timeout: int = settings.get_int(session_id, "LogonTimeout", 10)
88
+ self._logout_timeout: int = settings.get_int(session_id, "LogoutTimeout", 10)
89
+ self._is_initiator: bool = (
90
+ settings.get_or(session_id, "ConnectionType", "initiator").lower() == "initiator"
91
+ )
92
+ self._reset_on_logon: bool = settings.get_bool(session_id, "ResetOnLogon", False)
93
+ self._reset_on_logout: bool = settings.get_bool(session_id, "ResetOnLogout", False)
94
+ self._reset_on_disconnect: bool = settings.get_bool(session_id, "ResetOnDisconnect", False)
95
+
96
+ # Runtime state
97
+ self._state: SessionState = SessionState.DISCONNECTED
98
+ self._send_fn: SendFn | None = None
99
+ self._heartbeat_task: asyncio.Task[None] | None = None
100
+ self._last_send_time: float = 0.0
101
+ self._last_recv_time: float = 0.0
102
+ self._test_req_id: str | None = None
103
+ self._test_req_sent_at: float = 0.0
104
+ self._logon_sent_at: float = 0.0
105
+
106
+ self._app.on_create(session_id)
107
+
108
+ # ------------------------------------------------------------------
109
+ # Transport interface
110
+ # ------------------------------------------------------------------
111
+
112
+ async def on_connect(self, send_fn: SendFn) -> None:
113
+ """Called by transport when connection is established."""
114
+ self._send_fn = send_fn
115
+ self._last_recv_time = time.monotonic()
116
+
117
+ if self._reset_on_logon:
118
+ self._store.reset()
119
+
120
+ self._state = SessionState.LOGON_TIMEOUT
121
+ self._heartbeat_task = asyncio.create_task(self._timer_loop())
122
+
123
+ if self._is_initiator:
124
+ await self._send_logon()
125
+
126
+ async def on_disconnect(self) -> None:
127
+ """Called by transport when connection is lost."""
128
+ was_logged_on = self._state == SessionState.LOGGED_ON
129
+ self._state = SessionState.DISCONNECTED
130
+ self._send_fn = None
131
+
132
+ if self._heartbeat_task is not None:
133
+ self._heartbeat_task.cancel()
134
+ try:
135
+ await self._heartbeat_task
136
+ except asyncio.CancelledError:
137
+ pass
138
+ self._heartbeat_task = None
139
+
140
+ self._test_req_id = None
141
+
142
+ if self._reset_on_disconnect:
143
+ self._store.reset()
144
+
145
+ if was_logged_on:
146
+ self._app.on_logout(self._id)
147
+
148
+ self._log.on_event("Disconnected")
149
+
150
+ async def on_data(self, raw: bytes) -> None:
151
+ """Called by transport with a single complete framed FIX message."""
152
+ try:
153
+ msg = Message.decode(raw)
154
+ except ValueError as exc:
155
+ self._log.on_event(f"Decode error: {exc}")
156
+ return
157
+
158
+ self._log.on_incoming(raw.replace(b"\x01", b"|").decode("latin-1"))
159
+ self._last_recv_time = time.monotonic()
160
+ self._test_req_id = None # any incoming message clears a pending TestRequest
161
+
162
+ msg_type = msg.msg_type
163
+ if not msg_type:
164
+ return
165
+
166
+ # Header validation
167
+ if not self._validate_comp_ids(msg):
168
+ return
169
+
170
+ if not await self._check_seq_num(msg):
171
+ return
172
+
173
+ # Dispatch
174
+ if msg_type == MSG_LOGON:
175
+ await self._on_logon(msg)
176
+ elif msg_type == MSG_LOGOUT:
177
+ await self._on_logout(msg)
178
+ elif msg_type == MSG_HEARTBEAT:
179
+ await self._on_heartbeat(msg)
180
+ elif msg_type == MSG_TEST_REQUEST:
181
+ await self._on_test_request(msg)
182
+ elif msg_type == MSG_RESEND_REQUEST:
183
+ await self._on_resend_request(msg)
184
+ elif msg_type == MSG_SEQUENCE_RESET:
185
+ await self._on_sequence_reset(msg)
186
+ elif msg_type == MSG_REJECT:
187
+ await self._on_reject(msg)
188
+ else:
189
+ if self._state != SessionState.LOGGED_ON:
190
+ self._log.on_event(f"Received app message {msg_type!r} while not logged on — ignoring")
191
+ return
192
+ self._app.from_app(msg, self._id)
193
+
194
+ # ------------------------------------------------------------------
195
+ # User-facing send API
196
+ # ------------------------------------------------------------------
197
+
198
+ async def send_app(self, message: Message) -> None:
199
+ """Send an application message. Raises RuntimeError if not logged on."""
200
+ if self._state != SessionState.LOGGED_ON:
201
+ raise RuntimeError(f"Session {self._id} is not logged on")
202
+ await self._send_message(message, is_admin=False)
203
+
204
+ async def send_logout(self, text: str = "") -> None:
205
+ """Initiate a graceful logout."""
206
+ await self._send_logout(text)
207
+
208
+ # ------------------------------------------------------------------
209
+ # Properties
210
+ # ------------------------------------------------------------------
211
+
212
+ @property
213
+ def session_id(self) -> SessionID:
214
+ return self._id
215
+
216
+ @property
217
+ def state(self) -> SessionState:
218
+ return self._state
219
+
220
+ @property
221
+ def is_logged_on(self) -> bool:
222
+ return self._state == SessionState.LOGGED_ON
223
+
224
+ # ------------------------------------------------------------------
225
+ # Incoming admin message handlers
226
+ # ------------------------------------------------------------------
227
+
228
+ async def _on_logon(self, msg: Message) -> None:
229
+ if self._state == SessionState.LOGGED_ON:
230
+ self._log.on_event("Received second Logon while already logged on — ignoring")
231
+ return
232
+
233
+ reset_flag = msg.header.get_or(TAG_RESET_SEQ_NUM_FLAG, "N").upper() == "Y"
234
+ if reset_flag:
235
+ self._store.reset()
236
+ # seq num was already consumed by _check_seq_num; set next target to 2
237
+ self._store.set_next_target_msg_seq_num(2)
238
+
239
+ if not self._is_initiator:
240
+ # Acceptor: respond with Logon
241
+ await self._send_logon()
242
+
243
+ self._state = SessionState.LOGGED_ON
244
+ self._app.on_logon(self._id)
245
+ self._log.on_event("Logon complete")
246
+
247
+ async def _on_logout(self, msg: Message) -> None:
248
+ text = msg.get_field_or(TAG_TEXT, "")
249
+ self._log.on_event(f"Received Logout{': ' + text if text else ''}")
250
+
251
+ if self._state == SessionState.LOGOUT_TIMEOUT:
252
+ # We sent Logout first; counterparty confirmed — clean disconnect
253
+ self._state = SessionState.DISCONNECTED
254
+ else:
255
+ # Counterparty-initiated logout — echo back and clean up
256
+ await self._send_logout()
257
+
258
+ self._app.on_logout(self._id)
259
+ self._log.on_event("Logout complete")
260
+
261
+ async def _on_heartbeat(self, msg: Message) -> None:
262
+ # If this is a response to our TestRequest, clear the pending state
263
+ test_req_id = msg.get_field_or(TAG_TEST_REQ_ID)
264
+ if test_req_id and test_req_id == self._test_req_id:
265
+ self._test_req_id = None
266
+ self._app.from_admin(msg, self._id)
267
+
268
+ async def _on_test_request(self, msg: Message) -> None:
269
+ test_req_id = msg.get_field_or(TAG_TEST_REQ_ID, "")
270
+ await self._send_heartbeat(test_req_id=test_req_id)
271
+ self._app.from_admin(msg, self._id)
272
+
273
+ async def _on_resend_request(self, msg: Message) -> None:
274
+ begin = int(msg.get_field_or(TAG_BEGIN_SEQ_NO, "1"))
275
+ end = int(msg.get_field_or(TAG_END_SEQ_NO, "0")) # 0 = infinity
276
+ if end == 0:
277
+ end = self._store.next_sender_msg_seq_num() - 1
278
+
279
+ self._log.on_event(f"ResendRequest [{begin},{end}]")
280
+ await self._resend_range(begin, end)
281
+ self._app.from_admin(msg, self._id)
282
+
283
+ async def _on_sequence_reset(self, msg: Message) -> None:
284
+ new_seq_no = int(msg.get_field_or(TAG_NEW_SEQ_NO, "1"))
285
+ gap_fill = msg.get_field_or(TAG_GAP_FILL_FLAG, "N").upper() == "Y"
286
+
287
+ if gap_fill:
288
+ self._log.on_event(f"GapFill: next expected seq → {new_seq_no}")
289
+ else:
290
+ self._log.on_event(f"SequenceReset: next expected seq → {new_seq_no}")
291
+
292
+ self._store.set_next_target_msg_seq_num(new_seq_no)
293
+ self._app.from_admin(msg, self._id)
294
+
295
+ async def _on_reject(self, msg: Message) -> None:
296
+ ref_seq = msg.get_field_or(TAG_REF_SEQ_NUM, "?")
297
+ reason = msg.get_field_or(TAG_SESSION_REJECT_REASON, "")
298
+ text = msg.get_field_or(TAG_TEXT, "")
299
+ self._log.on_event(f"Session Reject for seq {ref_seq}: {reason} {text}".strip())
300
+ self._app.from_admin(msg, self._id)
301
+
302
+ # ------------------------------------------------------------------
303
+ # Outgoing admin message builders
304
+ # ------------------------------------------------------------------
305
+
306
+ async def _send_logon(self) -> None:
307
+ msg = Message()
308
+ msg.header.set(TAG_MSG_TYPE, MSG_LOGON)
309
+ msg.set_field(TAG_ENCRYPT_METHOD, "0")
310
+ msg.set_field(TAG_HEART_BT_INT, str(self._heartbt_int))
311
+ self._app.to_admin(msg, self._id)
312
+ await self._send_message(msg, is_admin=True)
313
+ self._logon_sent_at = time.monotonic()
314
+
315
+ async def _send_logout(self, text: str = "") -> None:
316
+ msg = Message()
317
+ msg.header.set(TAG_MSG_TYPE, MSG_LOGOUT)
318
+ if text:
319
+ msg.set_field(TAG_TEXT, text)
320
+ self._app.to_admin(msg, self._id)
321
+ await self._send_message(msg, is_admin=True)
322
+ self._state = SessionState.LOGOUT_TIMEOUT
323
+
324
+ async def _send_heartbeat(self, test_req_id: str = "") -> None:
325
+ msg = Message()
326
+ msg.header.set(TAG_MSG_TYPE, MSG_HEARTBEAT)
327
+ if test_req_id:
328
+ msg.set_field(TAG_TEST_REQ_ID, test_req_id)
329
+ self._app.to_admin(msg, self._id)
330
+ await self._send_message(msg, is_admin=True)
331
+
332
+ async def _send_test_request(self, test_req_id: str) -> None:
333
+ msg = Message()
334
+ msg.header.set(TAG_MSG_TYPE, MSG_TEST_REQUEST)
335
+ msg.set_field(TAG_TEST_REQ_ID, test_req_id)
336
+ self._app.to_admin(msg, self._id)
337
+ await self._send_message(msg, is_admin=True)
338
+ self._test_req_id = test_req_id
339
+ self._test_req_sent_at = time.monotonic()
340
+
341
+ async def _send_resend_request(self, begin_seq: int, end_seq: int) -> None:
342
+ msg = Message()
343
+ msg.header.set(TAG_MSG_TYPE, MSG_RESEND_REQUEST)
344
+ msg.set_field(TAG_BEGIN_SEQ_NO, str(begin_seq))
345
+ msg.set_field(TAG_END_SEQ_NO, str(end_seq))
346
+ self._app.to_admin(msg, self._id)
347
+ await self._send_message(msg, is_admin=True)
348
+
349
+ async def _send_sequence_reset(self, new_seq_no: int, gap_fill: bool = False) -> None:
350
+ msg = Message()
351
+ msg.header.set(TAG_MSG_TYPE, MSG_SEQUENCE_RESET)
352
+ msg.set_field(TAG_GAP_FILL_FLAG, "Y" if gap_fill else "N")
353
+ msg.set_field(TAG_NEW_SEQ_NO, str(new_seq_no))
354
+ # For gap-fill: override the seq num to be the beginning of the gap, not current
355
+ await self._send_message(msg, is_admin=True)
356
+
357
+ async def _send_reject(
358
+ self,
359
+ ref_seq_num: int,
360
+ ref_tag: int | None = None,
361
+ ref_msg_type: str | None = None,
362
+ reason: int | None = None,
363
+ text: str = "",
364
+ ) -> None:
365
+ msg = Message()
366
+ msg.header.set(TAG_MSG_TYPE, MSG_REJECT)
367
+ msg.set_field(TAG_REF_SEQ_NUM, str(ref_seq_num))
368
+ if ref_tag is not None:
369
+ msg.set_field(TAG_REF_TAG_ID, str(ref_tag))
370
+ if ref_msg_type is not None:
371
+ msg.set_field(TAG_REF_MSG_TYPE, ref_msg_type)
372
+ if reason is not None:
373
+ msg.set_field(TAG_SESSION_REJECT_REASON, str(reason))
374
+ if text:
375
+ msg.set_field(TAG_TEXT, text)
376
+ self._app.to_admin(msg, self._id)
377
+ await self._send_message(msg, is_admin=True)
378
+
379
+ # ------------------------------------------------------------------
380
+ # Resend logic
381
+ # ------------------------------------------------------------------
382
+
383
+ async def _resend_range(self, begin: int, end: int) -> None:
384
+ """Replay stored messages [begin, end], gap-filling admin messages."""
385
+ stored = self._store.get(begin, end)
386
+ seq = begin
387
+ gap_start: int | None = None
388
+
389
+ for raw in stored:
390
+ try:
391
+ msg = Message.decode(raw)
392
+ except ValueError:
393
+ continue
394
+
395
+ msg_seq = int(msg.header.get_or(TAG_MSG_SEQ_NUM, "0"))
396
+ msg_type = msg.msg_type
397
+
398
+ if msg_type in ADMIN_MSG_TYPES:
399
+ # Admin messages are gap-filled, not re-sent
400
+ if gap_start is None:
401
+ gap_start = msg_seq
402
+ else:
403
+ # Flush any pending gap-fill before this app message
404
+ if gap_start is not None:
405
+ await self._send_sequence_reset(msg_seq, gap_fill=True)
406
+ gap_start = None
407
+
408
+ # Re-send with PossDupFlag
409
+ msg.header.set(TAG_POSS_DUP_FLAG, "Y")
410
+ msg.header.set(TAG_ORIG_SENDING_TIME, msg.header.get_or(TAG_SENDING_TIME))
411
+ msg.header.set(TAG_SENDING_TIME, _utc_timestamp())
412
+ self._app.to_app(msg, self._id)
413
+ if self._send_fn is not None:
414
+ raw_out = msg.encode()
415
+ self._log.on_outgoing(raw_out.replace(b"\x01", b"|").decode("latin-1"))
416
+ await self._send_fn(raw_out)
417
+
418
+ seq = msg_seq + 1
419
+
420
+ # If messages at the tail were all admin, close the gap
421
+ if gap_start is not None:
422
+ await self._send_sequence_reset(end + 1, gap_fill=True)
423
+
424
+ # ------------------------------------------------------------------
425
+ # Core send path
426
+ # ------------------------------------------------------------------
427
+
428
+ async def _send_message(self, msg: Message, *, is_admin: bool) -> None:
429
+ """Stamp header, store, log, and transmit *msg*."""
430
+ seq_num = self._store.next_sender_msg_seq_num()
431
+ msg.header.set(TAG_BEGIN_STRING, self._id.begin_string)
432
+ msg.header.set(TAG_SENDER_COMP_ID, self._id.sender_comp_id)
433
+ msg.header.set(TAG_TARGET_COMP_ID, self._id.target_comp_id)
434
+ msg.header.set(TAG_MSG_SEQ_NUM, str(seq_num))
435
+ msg.header.set(TAG_SENDING_TIME, _utc_timestamp())
436
+
437
+ raw = msg.encode()
438
+ self._store.set(seq_num, raw)
439
+ self._store.incr_next_sender_msg_seq_num()
440
+ self._last_send_time = time.monotonic()
441
+
442
+ self._log.on_outgoing(raw.replace(b"\x01", b"|").decode("latin-1"))
443
+
444
+ if self._send_fn is not None:
445
+ await self._send_fn(raw)
446
+
447
+ # ------------------------------------------------------------------
448
+ # Sequence number validation
449
+ # ------------------------------------------------------------------
450
+
451
+ def _validate_comp_ids(self, msg: Message) -> bool:
452
+ sender = msg.header.get_or(TAG_SENDER_COMP_ID)
453
+ target = msg.header.get_or(TAG_TARGET_COMP_ID)
454
+ if sender != self._id.target_comp_id or target != self._id.sender_comp_id:
455
+ self._log.on_event(
456
+ f"CompID mismatch: got {sender}->{target}, "
457
+ f"expected {self._id.target_comp_id}->{self._id.sender_comp_id}"
458
+ )
459
+ return False
460
+ return True
461
+
462
+ async def _check_seq_num(self, msg: Message) -> bool:
463
+ """Validate incoming MsgSeqNum.
464
+
465
+ Returns True if processing should continue, False to drop the message.
466
+ """
467
+ seq_str = msg.header.get_or(TAG_MSG_SEQ_NUM, "0")
468
+ try:
469
+ seq_num = int(seq_str)
470
+ except ValueError:
471
+ await self._send_reject(0, ref_tag=TAG_MSG_SEQ_NUM, reason=6)
472
+ return False
473
+
474
+ expected = self._store.next_target_msg_seq_num()
475
+
476
+ if seq_num == expected:
477
+ self._store.incr_next_target_msg_seq_num()
478
+ return True
479
+
480
+ if seq_num > expected:
481
+ self._log.on_event(f"MsgSeqNum gap: expected {expected}, got {seq_num} — sending ResendRequest")
482
+ await self._send_resend_request(expected, 0)
483
+ return False
484
+
485
+ # seq_num < expected
486
+ poss_dup = msg.header.get_or(TAG_POSS_DUP_FLAG, "N").upper() == "Y"
487
+ if poss_dup:
488
+ # Silently discard duplicate
489
+ return False
490
+
491
+ # Sequence number too low and not a dup — serious error
492
+ self._log.on_event(f"MsgSeqNum too low: expected {expected}, got {seq_num} — logging out")
493
+ await self._send_logout(f"MsgSeqNum too low, expected {expected} got {seq_num}")
494
+ return False
495
+
496
+ # ------------------------------------------------------------------
497
+ # Timer loop (runs as asyncio Task)
498
+ # ------------------------------------------------------------------
499
+
500
+ async def _timer_loop(self) -> None:
501
+ """Background task: drives heartbeat and test-request logic."""
502
+ while self._state != SessionState.DISCONNECTED:
503
+ await asyncio.sleep(1)
504
+ now = time.monotonic()
505
+
506
+ if self._state == SessionState.LOGON_TIMEOUT:
507
+ # Check logon timeout (initiator)
508
+ if (
509
+ self._is_initiator
510
+ and self._logon_sent_at > 0
511
+ and now - self._logon_sent_at > self._logon_timeout
512
+ ):
513
+ self._log.on_event("Logon timeout — disconnecting")
514
+ self._state = SessionState.DISCONNECTED
515
+ continue
516
+
517
+ if self._state != SessionState.LOGGED_ON:
518
+ continue
519
+
520
+ # Heartbeat: send if we've been idle for HeartBtInt seconds
521
+ if now - self._last_send_time >= self._heartbt_int:
522
+ await self._send_heartbeat()
523
+
524
+ # Test request: send if we haven't received anything
525
+ recv_age = now - self._last_recv_time
526
+ if recv_age >= self._heartbt_int + 1:
527
+ if self._test_req_id is None:
528
+ test_req_id = str(int(now))
529
+ await self._send_test_request(test_req_id)
530
+ elif now - self._test_req_sent_at >= self._heartbt_int:
531
+ self._log.on_event("TestRequest timed out — disconnecting")
532
+ self._state = SessionState.DISCONNECTED
@@ -0,0 +1,32 @@
1
+ """SessionID — immutable identifier for a FIX session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SessionID:
10
+ """Uniquely identifies a FIX session.
11
+
12
+ Attributes
13
+ ----------
14
+ begin_string:
15
+ FIX version, e.g. ``"FIX.4.2"``, ``"FIX.4.4"``, ``"FIXT.1.1"``.
16
+ sender_comp_id:
17
+ SenderCompID of the local party.
18
+ target_comp_id:
19
+ TargetCompID of the remote party.
20
+ qualifier:
21
+ Optional disambiguator when two sessions share the same
22
+ BeginString/SenderCompID/TargetCompID triple.
23
+ """
24
+
25
+ begin_string: str
26
+ sender_comp_id: str
27
+ target_comp_id: str
28
+ qualifier: str = ""
29
+
30
+ def __str__(self) -> str:
31
+ base = f"{self.begin_string}:{self.sender_comp_id}->{self.target_comp_id}"
32
+ return f"{base}:{self.qualifier}" if self.qualifier else base