pycfdp 0.2.2__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.
Files changed (53) hide show
  1. cfdp/__init__.py +106 -0
  2. cfdp/checksum.py +94 -0
  3. cfdp/constants.py +153 -0
  4. cfdp/core.py +895 -0
  5. cfdp/event.py +55 -0
  6. cfdp/filestore/__init__.py +2 -0
  7. cfdp/filestore/base.py +99 -0
  8. cfdp/filestore/native.py +162 -0
  9. cfdp/meta/__init__.py +3 -0
  10. cfdp/meta/datafield.py +11 -0
  11. cfdp/meta/filestore.py +127 -0
  12. cfdp/meta/message.py +659 -0
  13. cfdp/pdu/__init__.py +8 -0
  14. cfdp/pdu/ack.py +90 -0
  15. cfdp/pdu/eof.py +123 -0
  16. cfdp/pdu/filedata.py +90 -0
  17. cfdp/pdu/finished.py +99 -0
  18. cfdp/pdu/header.py +159 -0
  19. cfdp/pdu/keep_alive.py +108 -0
  20. cfdp/pdu/metadata.py +211 -0
  21. cfdp/pdu/nak.py +220 -0
  22. cfdp/pdu/prompt.py +75 -0
  23. cfdp/pure/__init__.py +68 -0
  24. cfdp/pure/config.py +141 -0
  25. cfdp/pure/indications.py +130 -0
  26. cfdp/pure/machines/__init__.py +25 -0
  27. cfdp/pure/machines/receiver1.py +433 -0
  28. cfdp/pure/machines/receiver2.py +893 -0
  29. cfdp/pure/machines/sender1.py +400 -0
  30. cfdp/pure/machines/sender2.py +774 -0
  31. cfdp/pure/outputs.py +203 -0
  32. cfdp/pure/query_port.py +48 -0
  33. cfdp/pure/transaction.py +43 -0
  34. cfdp/py.typed +0 -0
  35. cfdp/remote_entity_config.py +12 -0
  36. cfdp/shell/__init__.py +44 -0
  37. cfdp/shell/checksum_service.py +136 -0
  38. cfdp/shell/dispatcher.py +185 -0
  39. cfdp/shell/effect_executor.py +285 -0
  40. cfdp/shell/machine_registry.py +165 -0
  41. cfdp/shell/query_port.py +70 -0
  42. cfdp/shell/timer_service.py +252 -0
  43. cfdp/transaction_handle.py +50 -0
  44. cfdp/transport/__init__.py +0 -0
  45. cfdp/transport/base.py +12 -0
  46. cfdp/transport/spacepacket.py +47 -0
  47. cfdp/transport/udp.py +90 -0
  48. cfdp/transport/zmq.py +54 -0
  49. pycfdp-0.2.2.dist-info/METADATA +76 -0
  50. pycfdp-0.2.2.dist-info/RECORD +53 -0
  51. pycfdp-0.2.2.dist-info/WHEEL +5 -0
  52. pycfdp-0.2.2.dist-info/licenses/LICENSE.txt +19 -0
  53. pycfdp-0.2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ """Pure Class-1 (unacknowledged) sender machine (issue #14).
2
+
3
+ A pure function of ``(state, event)`` returning an ordered ``list[Output]``. It
4
+ obeys the machine contract:
5
+
6
+ Sender1(transaction, config, query_port)
7
+ update_state(event) -> list[Output]
8
+
9
+ No ``kernel`` reference, no threads, no open file descriptors, no wall clock.
10
+ The machine builds PDUs itself (pure) and returns them in :class:`SendPdu`; it
11
+ reads file bytes, size, and transport readiness through the injected
12
+ :class:`~cfdp.pure.query_port.QueryPort`, synchronously (design D3/D5). The EOF
13
+ *checksum* is computed asynchronously (issue #21): the machine emits
14
+ :class:`RequestChecksum` and sends the EOF in the ``E42_CHECKSUM_READY``
15
+ continuation so a large-file checksum never blocks the dispatcher (design R1).
16
+ File open/close is a returned effect (:class:`OpenSource` / :class:`CloseFile`);
17
+ the shell owns the handle (design D10).
18
+
19
+ Class 1 has no timers and no NAKs, so this machine emits no timer effects.
20
+
21
+ Verified by differential trace testing against frozen golden fixtures captured
22
+ from the pre-cutover implementation — see ``tests/test_pure_sender1.py``.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from cfdp.constants import (
28
+ ConditionCode,
29
+ DeliveryCode,
30
+ Direction,
31
+ FileStatus,
32
+ MachineState,
33
+ TransmissionMode,
34
+ )
35
+ from cfdp.event import Event, EventType
36
+ from cfdp.pdu import EofPdu, FiledataPdu, MetadataPdu
37
+
38
+ from .. import indications
39
+ from ..config import TransactionConfig
40
+ from ..outputs import (
41
+ CloseFile,
42
+ InternalEvent,
43
+ OpenSource,
44
+ Output,
45
+ RequestChecksum,
46
+ SendPdu,
47
+ )
48
+ from ..query_port import QueryPort
49
+ from ..transaction import Transaction
50
+
51
+
52
+ class Sender1:
53
+ """Pure implementation of the Class 1 (unacknowledged transfer) sender."""
54
+
55
+ def __init__(
56
+ self,
57
+ transaction: Transaction,
58
+ config: TransactionConfig,
59
+ query_port: QueryPort,
60
+ *,
61
+ messages_to_user: list | None = None,
62
+ filestore_requests: list | None = None,
63
+ ) -> None:
64
+ self.transaction = transaction
65
+ self.config = config
66
+ self.query_port = query_port
67
+
68
+ # Request payload arrives with the creating put request, not on the
69
+ # frozen Transaction descriptor (design D3, "Data model"). Class-1
70
+ # F-series scenarios carry neither, so both default to None.
71
+ self.messages_to_user = messages_to_user
72
+ self.filestore_requests = filestore_requests
73
+
74
+ self.transmission_mode = TransmissionMode.UNACKNOWLEDGED
75
+ self.state = MachineState.SEND_METADATA
76
+
77
+ # Runtime state (plain data; formerly split across Machine + Transaction).
78
+ self._initialize()
79
+
80
+ # -- state reset (mirrors Machine.initialize) -------------------------- #
81
+
82
+ def _initialize(self):
83
+ # as per CCSDS 720.2-G-3, Page 5-13, g)
84
+ self.condition_code = ConditionCode.NO_ERROR
85
+ self.delivery_code = DeliveryCode.DATA_INCOMPLETE
86
+ self.frozen = False
87
+ self.suspended = False
88
+ self.file_status = FileStatus.UNREPORTED
89
+ self.filestore_responses = None
90
+ self.status_report = None
91
+
92
+ # Send-side file position (formerly Transaction._file_pos / _file_size).
93
+ self._file_pos = 0
94
+ self._file_size = None
95
+ # Whether a source file handle is open (formerly transaction.file_handle
96
+ # truthiness). shutdown() closes the handle only if one was opened, so a
97
+ # metadata-only transfer emits no CloseFile.
98
+ self._source_open = False
99
+ # The Class-1 sender never advances progress (only the receive side does),
100
+ # so resumed/abandoned indications always report 0 — matching the old
101
+ # machine's Transaction.get_progress().
102
+ self._progress = 0
103
+ # Async-checksum cache (issue #21). ``None`` until the shell computes the
104
+ # whole-file checksum and injects E42_CHECKSUM_READY. Cached exactly like
105
+ # the old ``Transaction.get_file_checksum`` memoized it, so every EOF
106
+ # (first send, cancellation) reuses the one value. ``_pending_eof`` holds
107
+ # the "finish sending the EOF" continuation deferred until the value
108
+ # arrives; ``None`` means nothing is waiting.
109
+ self._checksum = None
110
+ self._pending_eof = None
111
+
112
+ # -- the single input door --------------------------------------------- #
113
+
114
+ def update_state(self, event: Event) -> list[Output]:
115
+ """See state table given in CCSDS 720.2-G-3, Table 5-1.
116
+
117
+ Returns the ordered list of :class:`~cfdp.pure.outputs.Output` produced
118
+ by handling ``event``.
119
+ """
120
+ self._out: list[Output] = []
121
+
122
+ if self.state == MachineState.SEND_METADATA:
123
+ self._update_send_metadata(event)
124
+ elif self.state == MachineState.SEND_FILE:
125
+ self._update_send_file(event)
126
+
127
+ out, self._out = self._out, []
128
+ return out
129
+
130
+ # -- SEND_METADATA state ----------------------------------------------- #
131
+
132
+ def _update_send_metadata(self, event):
133
+ if event.type == EventType.E0_ENTERED_STATE:
134
+ self._initialize()
135
+
136
+ elif event.type == EventType.E30_RECEIVED_PUT_REQUEST:
137
+ self._issue_transaction_indication()
138
+ self._send_metadata()
139
+ if self._is_file_transfer():
140
+ self.state = MachineState.SEND_FILE
141
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
142
+ else:
143
+ self._send_eof()
144
+ self._issue_eof_sent_indication()
145
+ self._issue_transaction_finished_indication()
146
+ self._shutdown()
147
+
148
+ # -- SEND_FILE state --------------------------------------------------- #
149
+
150
+ def _update_send_file(self, event):
151
+ if event.type == EventType.E0_ENTERED_STATE:
152
+ self._open_sourcefile()
153
+ self._emit(InternalEvent(EventType.E1_SEND_FILE_DATA))
154
+
155
+ elif event.type == EventType.E1_SEND_FILE_DATA:
156
+ if not self.suspended and not self.frozen:
157
+ if self._is_comm_layer_ready():
158
+ self._send_file_data()
159
+ if self._is_entire_file_sent():
160
+ self._begin_eof(self._complete_after_eof)
161
+ return # stop here
162
+ self._emit(InternalEvent(EventType.E1_SEND_FILE_DATA))
163
+
164
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
165
+ self._issue_abandoned_indication()
166
+ self._shutdown()
167
+
168
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
169
+ self._begin_eof(self._cancel_after_eof)
170
+
171
+ elif event.type == EventType.E42_CHECKSUM_READY:
172
+ self._checksum = event.checksum
173
+ self._resume_eof()
174
+
175
+ elif event.type == EventType.E4_NOTICE_OF_SUSPENSION:
176
+ if not self.suspended:
177
+ self._issue_suspended_indication()
178
+ self.suspended = True
179
+
180
+ elif event.type == EventType.E31_RECEIVED_SUSPEND_REQUEST:
181
+ self._emit(InternalEvent(EventType.E4_NOTICE_OF_SUSPENSION))
182
+
183
+ elif event.type == EventType.E32_RECEIVED_RESUME_REQUEST:
184
+ if self.suspended:
185
+ self._issue_resumed_indication()
186
+ self.suspended = False
187
+ if not self.frozen:
188
+ self._emit(InternalEvent(EventType.E1_SEND_FILE_DATA))
189
+
190
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
191
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
192
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
193
+
194
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
195
+ self._issue_report_indication()
196
+
197
+ elif event.type == EventType.E40_RECEIVED_FREEZE:
198
+ self.frozen = True
199
+
200
+ elif event.type == EventType.E41_RECEIVED_THAW:
201
+ if self.frozen:
202
+ self.frozen = False
203
+ if not self.suspended:
204
+ self._emit(InternalEvent(EventType.E1_SEND_FILE_DATA))
205
+
206
+ # -- output helper ----------------------------------------------------- #
207
+
208
+ def _emit(self, output):
209
+ self._out.append(output)
210
+
211
+ # -- queries (predicates over pure state / the query port) ------------- #
212
+
213
+ def _is_file_transfer(self):
214
+ return bool(
215
+ self.transaction.source_filename
216
+ and len(self.transaction.source_filename) > 0
217
+ )
218
+
219
+ def _is_comm_layer_ready(self):
220
+ return self.query_port.transport_ready()
221
+
222
+ def _is_entire_file_sent(self):
223
+ return self._file_pos >= self._file_size_bytes()
224
+
225
+ def _file_size_bytes(self):
226
+ if self._file_size is None:
227
+ if self.transaction.source_filename:
228
+ self._file_size = self.query_port.size(self.transaction.source_filename)
229
+ else:
230
+ self._file_size = 0
231
+ return self._file_size
232
+
233
+ # -- effects: file lifecycle ------------------------------------------- #
234
+
235
+ def _open_sourcefile(self):
236
+ # The old machine runs the filestore fault handler if the source file is
237
+ # missing; Class-1 F-series scenarios only ever send existing files, so
238
+ # the happy path is a single OpenSource. (Fault handling on a missing
239
+ # source is Class-2 territory / a later increment.)
240
+ self._emit(OpenSource())
241
+ self._source_open = True
242
+
243
+ def _shutdown(self):
244
+ # Mirror Machine.shutdown: close the handle only if one was opened.
245
+ if self._source_open:
246
+ self._emit(CloseFile())
247
+ self._source_open = False
248
+ self.state = MachineState.COMPLETED
249
+
250
+ # -- effects: PDU builders (pure) -------------------------------------- #
251
+
252
+ def _header_fields(self) -> dict[str, Any]:
253
+ """The identity fields shared by every PDU this machine sends.
254
+
255
+ Direction, transmission mode, and the three transaction-identity fields
256
+ are the same on every outbound PDU; naming them once keeps each PDU
257
+ builder to only its distinguishing fields (splat with ``**``).
258
+ """
259
+ return {
260
+ "direction": Direction.TOWARD_RECEIVER,
261
+ "transmission_mode": self.transmission_mode,
262
+ "source_entity_id": self.transaction.source_entity_id,
263
+ "transaction_seq_number": self.transaction.seq_number,
264
+ "destination_entity_id": self.transaction.destination_entity_id,
265
+ }
266
+
267
+ def _send_metadata(self):
268
+ pdu = MetadataPdu(
269
+ **self._header_fields(),
270
+ file_size=self._file_size_bytes(),
271
+ source_filename=self.transaction.source_filename,
272
+ destination_filename=self.transaction.destination_filename,
273
+ checksum_type=self.transaction.checksum_type,
274
+ filestore_requests=self.filestore_requests,
275
+ messages_to_user=self.messages_to_user,
276
+ )
277
+ self._emit(SendPdu(pdu=pdu))
278
+
279
+ def _send_file_data(self):
280
+ offset = self._file_pos
281
+ _, data = self.query_port.read_segment(
282
+ offset, self.config.maximum_file_segment_length
283
+ )
284
+ # Advance by the bytes actually read, not the requested segment length: a
285
+ # backend that returns a short read for a non-final segment must not let
286
+ # _file_pos overshoot the real data end (which _is_entire_file_sent()'s
287
+ # >= would read as "done" and send EOF on a truncated transfer). For the
288
+ # native filestore every non-final read is full-length, so the emitted
289
+ # segment offsets are unchanged.
290
+ self._file_pos += len(data)
291
+ pdu = FiledataPdu(
292
+ **self._header_fields(),
293
+ segment_offset=offset,
294
+ file_data=data,
295
+ )
296
+ self._emit(SendPdu(pdu=pdu))
297
+
298
+ # -- async-checksum-gated EOF (issue #21) ------------------------------ #
299
+
300
+ def _begin_eof(self, continuation):
301
+ """Send the EOF, then run ``continuation`` — deferring on the checksum.
302
+
303
+ For a file transfer the EOF carries the whole-file checksum. Rather than
304
+ computing it synchronously on the event thread (design R1), the machine
305
+ emits :class:`RequestChecksum` and parks ``continuation`` until the shell
306
+ injects ``E42_CHECKSUM_READY``. A metadata-only transfer needs no real
307
+ checksum (value 0), so it proceeds immediately.
308
+ """
309
+ if self._is_file_transfer() and self._checksum is None:
310
+ if self._pending_eof is not None:
311
+ # A checksum request is already outstanding and we are being
312
+ # asked to send another EOF — the only re-entry is an E3
313
+ # cancellation, which includes the checksum-failure fallback
314
+ # where the shell injects E33 (not E42) because the computation
315
+ # raised. Do NOT re-park waiting for a second E42 that may never
316
+ # arrive: a Sender1 has no inactivity timer, so a missing event
317
+ # would strand the machine forever (defeating the issue #24
318
+ # "exactly one terminating event" guarantee). Resolve now with
319
+ # the best-available checksum (0 if it was never computed); a
320
+ # later E42, if any, finds nothing parked and no-ops.
321
+ self._pending_eof = None
322
+ self._send_eof()
323
+ continuation()
324
+ return
325
+ # First EOF: emit exactly one RequestChecksum and park until
326
+ # E42_CHECKSUM_READY arrives (a second request would spawn a
327
+ # duplicate worker and a redundant E42 — issue #21 review).
328
+ self._pending_eof = continuation
329
+ self._emit(RequestChecksum(checksum_type=self.transaction.checksum_type))
330
+ return
331
+ self._send_eof()
332
+ continuation()
333
+
334
+ def _resume_eof(self):
335
+ continuation, self._pending_eof = self._pending_eof, None
336
+ if continuation is None:
337
+ return
338
+ self._send_eof()
339
+ continuation()
340
+
341
+ def _complete_after_eof(self):
342
+ self._issue_eof_sent_indication()
343
+ self._issue_transaction_finished_indication()
344
+ self._shutdown()
345
+
346
+ def _cancel_after_eof(self):
347
+ self._issue_transaction_finished_indication()
348
+ self._shutdown()
349
+
350
+ def _send_eof(self):
351
+ pdu = EofPdu(
352
+ **self._header_fields(),
353
+ condition_code=self.condition_code,
354
+ file_checksum=self._file_checksum(),
355
+ file_size=self._file_size_bytes(),
356
+ )
357
+ self._emit(SendPdu(pdu=pdu))
358
+
359
+ def _file_checksum(self):
360
+ # The value is computed asynchronously (RequestChecksum -> E42) and
361
+ # cached; a metadata-only transfer has no file and checksums to 0.
362
+ if self.transaction.source_filename:
363
+ return self._checksum if self._checksum is not None else 0
364
+ return 0
365
+
366
+ # -- effects: indications ---------------------------------------------- #
367
+
368
+ def _issue_transaction_indication(self):
369
+ self._emit(indications.transaction(self.transaction.id))
370
+
371
+ def _issue_eof_sent_indication(self):
372
+ self._emit(indications.eof_sent(self.transaction.id))
373
+
374
+ def _issue_transaction_finished_indication(self):
375
+ self._emit(
376
+ indications.transaction_finished(
377
+ self.transaction.id,
378
+ self.condition_code,
379
+ self.file_status,
380
+ self.delivery_code,
381
+ self.filestore_responses,
382
+ self.status_report,
383
+ )
384
+ )
385
+
386
+ def _issue_suspended_indication(self):
387
+ self._emit(indications.suspended(self.transaction.id, self.condition_code))
388
+
389
+ def _issue_resumed_indication(self):
390
+ self._emit(indications.resumed(self.transaction.id, self._progress))
391
+
392
+ def _issue_report_indication(self):
393
+ self._emit(indications.report(self.transaction.id, self.status_report))
394
+
395
+ def _issue_abandoned_indication(self):
396
+ self._emit(
397
+ indications.abandoned(
398
+ self.transaction.id, self.condition_code, self._progress
399
+ )
400
+ )