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,433 @@
1
+ """Pure Class-1 (unacknowledged) receiver machine (issue #15).
2
+
3
+ A pure function of ``(state, event)`` returning an ordered ``list[Output]``. It
4
+ obeys the machine contract:
5
+
6
+ Receiver1(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
+
11
+ The receiver is **PDU-driven**: the triggering inbound PDU rides on the single
12
+ input door as ``event.pdu`` (see :class:`cfdp.event.Event`), so the contract's
13
+ one-door rule holds and no out-of-contract parameter is added. File handling is
14
+ expressed purely as effects — :class:`OpenTemp`, :class:`WriteSegment`,
15
+ :class:`Finalize`, :class:`CloseFile`. The temp-file *size* read-back is a
16
+ synchronous :class:`~cfdp.pure.query_port.QueryPort` call, but *checksum*
17
+ verification is asynchronous (issue #21): the machine emits
18
+ :class:`RequestChecksum` and completes in the ``E42_CHECKSUM_READY`` continuation
19
+ so a large-file checksum never blocks the dispatcher (design D3/D5/D10/R1). The
20
+ machine holds positions and a received-segment map, never a file descriptor.
21
+
22
+ Class 1 has no NAKs and only the inactivity timer, driven as
23
+ :class:`StartTimer` effects.
24
+
25
+ Verified by differential trace testing against frozen golden fixtures captured
26
+ from the pre-cutover implementation — see ``tests/test_pure_receiver1.py``. In
27
+ particular the pre-cutover machine closed the temp file **twice** on the happy
28
+ EOF path (``close_tempfile`` then ``shutdown``); this machine reproduces both
29
+ :class:`CloseFile` outputs on purpose (the second close is a no-op).
30
+ """
31
+
32
+ from cfdp.constants import (
33
+ ConditionCode,
34
+ DeliveryCode,
35
+ FaultHandlerAction,
36
+ FileStatus,
37
+ MachineState,
38
+ TransmissionMode,
39
+ )
40
+ from cfdp.event import Event, EventType
41
+
42
+ from .. import indications
43
+ from ..config import TransactionConfig
44
+ from ..outputs import (
45
+ CancelTimer,
46
+ CloseFile,
47
+ ExecuteFilestoreRequests,
48
+ Finalize,
49
+ InternalEvent,
50
+ OpenTemp,
51
+ Output,
52
+ PostUserMessage,
53
+ RequestChecksum,
54
+ StartTimer,
55
+ TimerKind,
56
+ WriteSegment,
57
+ )
58
+ from ..query_port import QueryPort
59
+ from ..transaction import Transaction
60
+
61
+
62
+ class Receiver1:
63
+ """Pure implementation of the Class 1 (unacknowledged transfer) receiver."""
64
+
65
+ def __init__(
66
+ self,
67
+ transaction: Transaction,
68
+ config: TransactionConfig,
69
+ query_port: QueryPort,
70
+ ) -> None:
71
+ self.transaction = transaction
72
+ self.config = config
73
+ self.query_port = query_port
74
+
75
+ self.transmission_mode = TransmissionMode.UNACKNOWLEDGED
76
+ self.state = MachineState.WAIT_FOR_MD
77
+
78
+ # Request payload received off the inbound Metadata PDU (design D3). The
79
+ # old machine stashes these on the mutable Transaction via
80
+ # process_metadata_options; here they are machine state.
81
+ self.filestore_requests = None
82
+ self.messages_to_user = None
83
+
84
+ self._initialize()
85
+
86
+ # -- state reset (mirrors Machine.initialize) -------------------------- #
87
+
88
+ def _initialize(self):
89
+ # as per CCSDS 720.2-G-3, Page 5-13, g)
90
+ self.condition_code = ConditionCode.NO_ERROR
91
+ self.delivery_code = DeliveryCode.DATA_INCOMPLETE
92
+ self.frozen = False
93
+ self.metadata_received = False
94
+ self.pdu_received = False
95
+ self.suspended = False
96
+ self.file_status = FileStatus.UNREPORTED
97
+ self.filestore_responses = None
98
+ self.status_report = None
99
+
100
+ self.received_file_size = 0
101
+ # Whether a temp file handle is open (formerly transaction.file_handle
102
+ # truthiness). shutdown() emits CloseFile only if one was opened.
103
+ self._temp_open = False
104
+ self.received_file_segments = {}
105
+ # Progress advances as segments arrive (formerly transaction._progress).
106
+ self._progress = 0
107
+ # Async-checksum verification (issue #21): the EOF-carried checksum to
108
+ # compare against, remembered across the RequestChecksum -> E42 hop while
109
+ # the shell computes the temp-file checksum off the event thread.
110
+ self._expected_checksum = 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-2.
116
+
117
+ Returns the ordered list of :class:`~cfdp.pure.outputs.Output` produced
118
+ by handling ``event``. The triggering inbound PDU, if any, is read from
119
+ ``event.pdu``.
120
+ """
121
+ self._out: list[Output] = []
122
+ pdu = getattr(event, "pdu", None)
123
+
124
+ # as per CCSDS 720.2-G-3, 5.3.7: any inbound PDU restarts inactivity.
125
+ if pdu is not None:
126
+ self._restart_inactivity_timer()
127
+
128
+ if self.state == MachineState.WAIT_FOR_MD:
129
+ self._update_wait_for_md(event, pdu)
130
+ elif self.state == MachineState.WAIT_FOR_EOF:
131
+ self._update_wait_for_eof(event, pdu)
132
+
133
+ out, self._out = self._out, []
134
+ return out
135
+
136
+ # -- WAIT_FOR_MD state ------------------------------------------------- #
137
+
138
+ def _update_wait_for_md(self, event, pdu):
139
+ if event.type == EventType.E0_ENTERED_STATE:
140
+ self._initialize()
141
+ self._restart_inactivity_timer()
142
+
143
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
144
+ self._issue_abandoned_indication()
145
+ self._shutdown()
146
+
147
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
148
+ self._issue_transaction_finished_indication()
149
+ self._shutdown()
150
+
151
+ elif event.type == EventType.E10_RECEIVED_METADATA:
152
+ self._issue_metadata_received_indication(pdu.file_size)
153
+ if self._is_file_transfer():
154
+ self._open_tempfile()
155
+ self._process_metadata_options(pdu)
156
+ self.state = MachineState.WAIT_FOR_EOF
157
+
158
+ elif event.type == EventType.E12_RECEIVED_EOF_NO_ERROR:
159
+ self._issue_transaction_finished_indication()
160
+ self._shutdown()
161
+
162
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
163
+ self.condition_code = pdu.condition_code
164
+ if pdu.fault_location is not None:
165
+ self.status_report = {"fault_location": pdu.fault_location}
166
+ self._issue_transaction_finished_indication()
167
+ self._shutdown()
168
+
169
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
170
+ self._restart_inactivity_timer()
171
+ self._fault_inactivity()
172
+
173
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
174
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
175
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
176
+
177
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
178
+ self._issue_report_indication()
179
+
180
+ # -- WAIT_FOR_EOF state ------------------------------------------------ #
181
+
182
+ def _update_wait_for_eof(self, event, pdu):
183
+ if event.type == EventType.E2_ABANDON_TRANSACTION:
184
+ self._issue_abandoned_indication()
185
+ self._shutdown()
186
+
187
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
188
+ self._issue_transaction_finished_indication()
189
+ self._shutdown()
190
+
191
+ elif event.type == EventType.E11_RECEIVED_FILEDATA:
192
+ self._issue_filesegment_received_indication(
193
+ pdu.segment_offset, len(pdu.file_data)
194
+ )
195
+ if self._is_file_transfer():
196
+ self._store_file_data(pdu)
197
+ self._update_received_file_size(pdu)
198
+
199
+ elif event.type == EventType.E12_RECEIVED_EOF_NO_ERROR:
200
+ self._issue_eof_received_indication()
201
+
202
+ if self._is_file_transfer():
203
+ if self._is_file_size_error(pdu.file_size):
204
+ if not self._fault_file_size():
205
+ self._close_tempfile()
206
+ return
207
+ # Checksum verification is async (issue #21): remember the EOF's
208
+ # checksum, ask the shell to compute the temp-file checksum off
209
+ # the event thread, and finish in the E42 continuation.
210
+ self._expected_checksum = pdu.file_checksum
211
+ self._emit(
212
+ RequestChecksum(
213
+ checksum_type=self.transaction.checksum_type,
214
+ from_temp_handle=True,
215
+ )
216
+ )
217
+ return
218
+
219
+ if self.filestore_requests:
220
+ self._execute_filestore_requests()
221
+
222
+ self._issue_transaction_finished_indication()
223
+ self._shutdown()
224
+
225
+ elif event.type == EventType.E42_CHECKSUM_READY:
226
+ self._finish_after_checksum(event.checksum)
227
+
228
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
229
+ self.condition_code = pdu.condition_code
230
+ if pdu.fault_location is not None:
231
+ self.status_report = {"fault_location": pdu.fault_location}
232
+ self._issue_transaction_finished_indication()
233
+ self._shutdown()
234
+
235
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
236
+ self._restart_inactivity_timer()
237
+ self._fault_inactivity()
238
+
239
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
240
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
241
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
242
+
243
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
244
+ self._issue_report_indication()
245
+
246
+ # -- output helper ----------------------------------------------------- #
247
+
248
+ def _emit(self, output):
249
+ self._out.append(output)
250
+
251
+ # -- queries / predicates ---------------------------------------------- #
252
+
253
+ def _is_file_transfer(self):
254
+ return bool(
255
+ self.transaction.source_filename
256
+ and len(self.transaction.source_filename) > 0
257
+ )
258
+
259
+ def _is_file_size_error(self, file_size):
260
+ return self.received_file_size > file_size
261
+
262
+ def _is_file_checksum_failure(self, expected, calculated):
263
+ # ``calculated`` is the temp-file checksum computed asynchronously by the
264
+ # shell (issue #21) and delivered on E42_CHECKSUM_READY; ``expected`` is
265
+ # the EOF-carried value.
266
+ return calculated != expected
267
+
268
+ def _finish_after_checksum(self, calculated_checksum):
269
+ """E42 continuation: verify checksum, finalize, and finish (issue #21)."""
270
+ if self._is_file_checksum_failure(self._expected_checksum, calculated_checksum):
271
+ if not self._fault_file_checksum():
272
+ self._close_tempfile()
273
+ return
274
+
275
+ self.delivery_code = DeliveryCode.DATA_COMPLETE
276
+ self._copy_tempfile_to_destfile()
277
+ self._close_tempfile()
278
+ self.file_status = FileStatus.RETAINED_SUCCESSFULLY
279
+
280
+ if self.filestore_requests:
281
+ self._execute_filestore_requests()
282
+
283
+ self._issue_transaction_finished_indication()
284
+ self._shutdown()
285
+
286
+ # -- timer effects ----------------------------------------------------- #
287
+
288
+ def _restart_inactivity_timer(self):
289
+ # Mirror Machine.restart_inactivity_timer: only while not suspended.
290
+ if not self.suspended:
291
+ self._emit(
292
+ StartTimer(
293
+ kind=TimerKind.INACTIVITY,
294
+ interval=self.config.transaction_inactivity_limit,
295
+ )
296
+ )
297
+
298
+ # -- fault handling ---------------------------------------------------- #
299
+
300
+ def _fault_inactivity(self):
301
+ return self._run_fault_handler(
302
+ self.config.get_fault_handler(ConditionCode.INACTIVITY_DETECTED)
303
+ )
304
+
305
+ def _fault_file_size(self):
306
+ return self._run_fault_handler(
307
+ self.config.get_fault_handler(ConditionCode.FILE_SIZE_ERROR)
308
+ )
309
+
310
+ def _fault_file_checksum(self):
311
+ return self._run_fault_handler(
312
+ self.config.get_fault_handler(ConditionCode.FILE_CHECKSUM_FAILURE)
313
+ )
314
+
315
+ def _run_fault_handler(self, fault_handler):
316
+ """Return True if execution should continue (IGNORE), False otherwise."""
317
+ if fault_handler == FaultHandlerAction.CANCEL:
318
+ self._emit(InternalEvent(EventType.E33_RECEIVED_CANCEL_REQUEST))
319
+ return False
320
+ elif fault_handler == FaultHandlerAction.SUSPEND:
321
+ self._emit(InternalEvent(EventType.E31_RECEIVED_SUSPEND_REQUEST))
322
+ return False
323
+ elif fault_handler == FaultHandlerAction.IGNORE:
324
+ return True
325
+ elif fault_handler == FaultHandlerAction.ABANDON:
326
+ self._emit(InternalEvent(EventType.E2_ABANDON_TRANSACTION))
327
+ return False
328
+ else:
329
+ raise ValueError
330
+
331
+ # -- file lifecycle effects -------------------------------------------- #
332
+
333
+ def _open_tempfile(self):
334
+ self._emit(OpenTemp())
335
+ self._temp_open = True
336
+
337
+ def _store_file_data(self, pdu):
338
+ self._emit(WriteSegment(offset=pdu.segment_offset, data=bytes(pdu.file_data)))
339
+ self.received_file_segments[pdu.segment_offset] = len(pdu.file_data)
340
+
341
+ def _copy_tempfile_to_destfile(self):
342
+ # Class-1 Metadata always arrives first, so the frozen transaction has
343
+ # the destination filename; carry it explicitly so the effect is
344
+ # self-describing (and matches the recorder's normalized Finalize).
345
+ self._emit(Finalize(destination_filename=self.transaction.destination_filename))
346
+
347
+ def _close_tempfile(self):
348
+ # Mirror Machine.close_tempfile: it does NOT clear the handle, so the
349
+ # subsequent shutdown() sees a live handle and closes it a second time.
350
+ self._emit(CloseFile())
351
+
352
+ def _update_received_file_size(self, pdu):
353
+ new_size = pdu.segment_offset + len(pdu.file_data)
354
+ if self.received_file_size < new_size:
355
+ self.received_file_size = new_size
356
+ self._progress = new_size
357
+
358
+ def _process_metadata_options(self, pdu):
359
+ self.filestore_requests = pdu.filestore_requests
360
+ self.messages_to_user = pdu.messages_to_user
361
+ for message in self.messages_to_user or []:
362
+ message.originating_transaction = self.transaction
363
+ self._emit(PostUserMessage(message=message))
364
+
365
+ def _execute_filestore_requests(self):
366
+ # Filestore-request execution is a shell effect (design D3/D10): the
367
+ # machine carries the request TLVs but never touches the filestore. ``put``
368
+ # permits filestore requests in ANY transmission mode, so a Class-1
369
+ # Metadata can carry them (issue #27); emit the equivalent command for the
370
+ # EffectExecutor to run, mirroring Receiver2._execute_filestore_requests.
371
+ # Both call sites are guarded by ``if self.filestore_requests``, so this is
372
+ # a no-op (no effect) when the transfer carries none — the existing golden
373
+ # fixtures, which never carry filestore requests, are unchanged.
374
+ self._emit(ExecuteFilestoreRequests(requests=self.filestore_requests))
375
+
376
+ def _shutdown(self):
377
+ # Mirror Receiver1.shutdown: first super().shutdown() (base Machine)
378
+ # closes the handle only if one was opened — on the happy EOF path
379
+ # close_tempfile already emitted a CloseFile without clearing the handle,
380
+ # so this emits a second CloseFile, matching the old machine (the
381
+ # differential oracle). Then inactivity_timer.shutdown() cancels the
382
+ # inactivity timer, which the recording timer emits as
383
+ # CancelTimer(INACTIVITY) — always, regardless of whether a file was
384
+ # open.
385
+ if self._temp_open:
386
+ self._emit(CloseFile())
387
+ self._temp_open = False
388
+ self._emit(CancelTimer(kind=TimerKind.INACTIVITY))
389
+ self.state = MachineState.COMPLETED
390
+
391
+ # -- indications ------------------------------------------------------- #
392
+
393
+ def _issue_metadata_received_indication(self, file_size):
394
+ self._emit(
395
+ indications.metadata_received(
396
+ self.transaction.id,
397
+ self.transaction.source_entity_id,
398
+ file_size,
399
+ self.transaction.source_filename,
400
+ self.transaction.destination_filename,
401
+ self.messages_to_user,
402
+ )
403
+ )
404
+
405
+ def _issue_filesegment_received_indication(self, offset, length):
406
+ self._emit(
407
+ indications.filesegment_received(self.transaction.id, offset, length)
408
+ )
409
+
410
+ def _issue_eof_received_indication(self):
411
+ self._emit(indications.eof_received(self.transaction.id))
412
+
413
+ def _issue_transaction_finished_indication(self):
414
+ self._emit(
415
+ indications.transaction_finished(
416
+ self.transaction.id,
417
+ self.condition_code,
418
+ self.file_status,
419
+ self.delivery_code,
420
+ self.filestore_responses,
421
+ self.status_report,
422
+ )
423
+ )
424
+
425
+ def _issue_report_indication(self):
426
+ self._emit(indications.report(self.transaction.id, self.status_report))
427
+
428
+ def _issue_abandoned_indication(self):
429
+ self._emit(
430
+ indications.abandoned(
431
+ self.transaction.id, self.condition_code, self._progress
432
+ )
433
+ )