ipfs-node 0.1.6__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,474 @@
1
+ from ipfs_tk_generics.pubsub import BasePubSub
2
+ import os
3
+ import tempfile
4
+ import ctypes
5
+ import shutil
6
+ import platform
7
+ import json
8
+ import time
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional, Union, List, Dict, Any, Callable, Tuple, Iterator, Set
13
+ import base64
14
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
15
+ from libkubo import libkubo, c_str, from_c_str, ffi
16
+
17
+
18
+ @dataclass
19
+ class IPFSMessage:
20
+ """
21
+ Represents a message received from the IPFS pubsub system.
22
+ """
23
+ senderID: str
24
+ """The peer ID of the message sender."""
25
+
26
+ data: bytes
27
+ """The message data as bytes."""
28
+
29
+ topic_id: str
30
+ """The topic this message was published to."""
31
+
32
+ seqno: Optional[bytes] = None
33
+ """Optional sequence number of the message."""
34
+
35
+ topics: Optional[List[str]] = None
36
+ """Optional list of topics this message was published to."""
37
+
38
+ @classmethod
39
+ def from_json(cls, json_data: str) -> 'IPFSMessage':
40
+ """
41
+ Create a message object from JSON string.
42
+
43
+ Args:
44
+ json_data: JSON string representation of a message.
45
+
46
+ Returns:
47
+ IPFSMessage: A new message object.
48
+ """
49
+ if not json_data:
50
+ raise ValueError("Empty JSON data")
51
+
52
+ data = json.loads(json_data)
53
+
54
+ # decode data field
55
+ data_bytes = bytes(urlsafe_b64decode(data.get('data')))
56
+
57
+ # Convert seqno field back to bytes
58
+ seqno = None
59
+ if data.get('seqno'):
60
+ if isinstance(data.get('seqno'), list):
61
+ seqno = bytes(data.get('seqno', []))
62
+ return cls(
63
+ senderID=data.get('from', ''),
64
+ data=data_bytes,
65
+ topic_id=data.get('topicID', ''),
66
+ seqno=seqno,
67
+ topics=data.get('topics')
68
+ )
69
+
70
+ def __str__(self) -> str:
71
+ """String representation of the message."""
72
+ try:
73
+ # Try to decode as UTF-8
74
+ data_str = self.data.decode('utf-8')
75
+ except UnicodeDecodeError:
76
+ # Fall back to hex representation
77
+ data_str = f"0x{self.data.hex()}"
78
+
79
+ return f"IPFSMessage(from={self.senderID}, topic={self.topic_id}, data={data_str})"
80
+
81
+ def __getitem__(self, key: str) -> Any:
82
+ return getattr(self, key)
83
+
84
+ def __setitem__(self, key: str, value: Any) -> None:
85
+ setattr(self, key, value)
86
+
87
+
88
+ class IPFSSubscription:
89
+ """
90
+ Represents a subscription to an IPFS pubsub topic.
91
+ """
92
+
93
+ def __init__(self, node: 'IpfsNode', sub_id: int, topic: str):
94
+ """
95
+ Initialize a subscription.
96
+
97
+ Args:
98
+ node: The IPFS node this subscription belongs to.
99
+ sub_id: The subscription ID from the Go wrapper.
100
+ topic: The topic subscribed to.
101
+ """
102
+ self._node = node
103
+ self._sub_id = sub_id
104
+ self._topic = topic
105
+ self._active = True
106
+ self._callback = None
107
+ self._callback_thread = None
108
+ self._stop_event = threading.Event()
109
+
110
+ # Get message queue ready
111
+ self._message_queue = []
112
+
113
+ @property
114
+ def topic(self) -> str:
115
+ """Get the topic name for this subscription."""
116
+ return self._topic
117
+
118
+ @property
119
+ def id(self) -> int:
120
+ """Get the subscription ID."""
121
+ return self._sub_id
122
+
123
+ @property
124
+ def active(self) -> bool:
125
+ """Check if the subscription is active."""
126
+ return self._active
127
+
128
+ def next_message(self, timeout: Optional[float] = None) -> Optional[IPFSMessage]:
129
+ """
130
+ Get the next message from this subscription.
131
+
132
+ Args:
133
+ timeout: Maximum time to wait in seconds. None means no timeout.
134
+
135
+ Returns:
136
+ IPFSMessage or None: The next message, or None if no message is available
137
+ before the timeout.
138
+ """
139
+ if not self._active:
140
+ raise RuntimeError("Subscription is no longer active")
141
+
142
+ start_time = time.time()
143
+ while timeout is None or (time.time() - start_time) < timeout:
144
+ # Try to get a message
145
+ message = self._node._pubsub_next_message(self._sub_id)
146
+ if message:
147
+ return message
148
+
149
+ # Wait a bit before trying again
150
+ time.sleep(0.1)
151
+
152
+ return None
153
+
154
+ def __iter__(self) -> Iterator[IPFSMessage]:
155
+ """
156
+ Iterate over incoming messages.
157
+
158
+ Yields:
159
+ IPFSMessage: Each message as it arrives.
160
+ """
161
+ while self._active:
162
+ msg = self.next_message(timeout=1.0)
163
+ if msg:
164
+ yield msg
165
+
166
+ def close(self) -> None:
167
+ """Close the subscription."""
168
+ if self._active:
169
+ self._stop_callback()
170
+ self._node._pubsub_unsubscribe(self._sub_id)
171
+ self._active = False
172
+
173
+ def __enter__(self) -> 'IPFSSubscription':
174
+ """Support for context manager protocol."""
175
+ return self
176
+
177
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
178
+ """Clean up when exiting the context manager."""
179
+ self.close()
180
+
181
+ def _callback_loop(self, callback: Callable[[IPFSMessage], None]) -> None:
182
+ """
183
+ Run the callback loop in a separate thread.
184
+
185
+ Args:
186
+ callback: Function to call for each message.
187
+ """
188
+ while not self._stop_event.is_set() and self._active:
189
+ try:
190
+ msg = self.next_message(timeout=0.5)
191
+ if msg:
192
+ callback(msg)
193
+ except Exception as e:
194
+ # Just log the error and continue
195
+ print(f"Error in subscription callback: {e}")
196
+
197
+ def _stop_callback(self) -> None:
198
+ """Stop the callback thread if running."""
199
+ if self._callback_thread is not None:
200
+ self._stop_event.set()
201
+ self._callback_thread.join(timeout=2.0)
202
+ self._callback_thread = None
203
+ self._stop_event.clear()
204
+
205
+ def subscribe(self, callback: Callable[[IPFSMessage], None] = None) -> None:
206
+ """
207
+ Set a callback to be called for each incoming message.
208
+
209
+ Args:
210
+ callback: Function to call for each message.
211
+ """
212
+ if not self._active:
213
+ raise RuntimeError("Subscription is no longer active")
214
+
215
+ # Stop any existing callback
216
+ self._stop_callback()
217
+
218
+ # Set the new callback
219
+ self._callback = callback
220
+
221
+ # Start a new thread to run the callback
222
+ self._stop_event.clear()
223
+ self._callback_thread = threading.Thread(
224
+ target=self._callback_loop,
225
+ args=(callback,),
226
+ daemon=True
227
+ )
228
+ self._callback_thread.start()
229
+
230
+ def terminate(self, *args, **kwargs):
231
+ return self.close()
232
+
233
+
234
+ class NodePubsub(BasePubSub):
235
+ def __init__(self, node):
236
+ self._node = node
237
+ self._repo_path = self._node._repo_path
238
+ self._subscriptions = {} # Track active subscriptions by topic
239
+
240
+ def subscribe(self, topic: str, callback: Callable[[IPFSMessage], None] | None = None) -> IPFSSubscription:
241
+ """
242
+ Subscribe to a pubsub topic.
243
+
244
+ Args:
245
+ topic: The topic to subscribe to.
246
+
247
+ Returns:
248
+ IPFSSubscription: A subscription object for the topic.
249
+ """
250
+ if not self._node._online:
251
+ raise RuntimeError("Cannot subscribe to topics in offline mode")
252
+
253
+ if not self._node._enable_pubsub:
254
+ raise RuntimeError("PubSub is not enabled for this node")
255
+
256
+ # Subscribe to the topic
257
+ repo_path = c_str(self._repo_path.encode('utf-8'))
258
+ topic_c = c_str(topic.encode('utf-8'))
259
+
260
+ sub_id = libkubo.PubSubSubscribe(repo_path, topic_c)
261
+ if sub_id < 0:
262
+ raise RuntimeError(f"Failed to subscribe to topic: {topic}")
263
+
264
+ # Create the subscription object
265
+ subscription = IPFSSubscription(self, sub_id, topic)
266
+
267
+ # Track the subscription
268
+ if topic not in self._subscriptions:
269
+ self._subscriptions[topic] = set()
270
+ self._subscriptions[topic].add(subscription)
271
+
272
+ if callback:
273
+ subscription.subscribe(callback)
274
+
275
+ return subscription
276
+
277
+ def publish(self, topic: str, data: Union[str, bytes]) -> bool:
278
+ """
279
+ Publish a message to a pubsub topic.
280
+
281
+ Args:
282
+ topic: The topic to publish to.
283
+ data: The message data to publish. If a string is provided, it will be
284
+ encoded as UTF-8 bytes.
285
+
286
+ Returns:
287
+ bool: True if the message was published successfully.
288
+ """
289
+ if not self._node._online:
290
+ raise RuntimeError("Cannot publish to topics in offline mode")
291
+
292
+ if not self._node._enable_pubsub:
293
+ raise RuntimeError("PubSub is not enabled for this node")
294
+
295
+ # Convert string to bytes if needed
296
+ if isinstance(data, str):
297
+ data_bytes = data.encode('utf-8')
298
+ else:
299
+ data_bytes = data
300
+
301
+ # Get the repository path
302
+ repo_path = c_str(self._repo_path.encode('utf-8'))
303
+ topic_c = c_str(topic.encode('utf-8'))
304
+
305
+ # Create a data buffer for the message
306
+ data_len = len(data_bytes)
307
+ data_buffer = ffi.new("char[]", data_bytes)
308
+ result = libkubo.PubSubPublish(
309
+ repo_path,
310
+ topic_c,
311
+ ffi.cast("void *", data_buffer),
312
+ len(data_bytes)
313
+ )
314
+
315
+ return result == 0
316
+
317
+ def list_peers(self, topic: Optional[str] = None) -> List[str]:
318
+ """
319
+ List peers participating in pubsub.
320
+
321
+ Args:
322
+ topic: Optional topic to filter peers. If None, returns all pubsub peers.
323
+
324
+ Returns:
325
+ List[str]: List of peer IDs.
326
+ """
327
+ if not self._node._online:
328
+ raise RuntimeError("Cannot list peers in offline mode")
329
+
330
+ if not self._node._enable_pubsub:
331
+ raise RuntimeError("PubSub is not enabled for this node")
332
+
333
+ # Get the repository path
334
+ repo_path = c_str(self._repo_path.encode('utf-8'))
335
+ topic_c = c_str((topic or "").encode('utf-8'))
336
+
337
+ # Get peers
338
+ peers_ptr = libkubo.PubSubPeers(repo_path, topic_c)
339
+ if not peers_ptr:
340
+ return []
341
+
342
+ # Copy the string content before freeing the pointer
343
+ json_data = from_c_str(peers_ptr)
344
+
345
+ try:
346
+ # Free the memory allocated in Go
347
+ libkubo.FreeString(peers_ptr)
348
+ except Exception as e:
349
+ print(f"Warning: Failed to free memory: {e}")
350
+
351
+ try:
352
+ # Parse the JSON array
353
+ return json.loads(json_data)
354
+ except json.JSONDecodeError:
355
+ return []
356
+
357
+ def list_topics(self) -> List[str]:
358
+ """
359
+ List subscribed pubsub topics.
360
+
361
+ Returns:
362
+ List[str]: List of topic names.
363
+ """
364
+ if not self._node._online:
365
+ raise RuntimeError("Cannot list topics in offline mode")
366
+
367
+ if not self._node._enable_pubsub:
368
+ raise RuntimeError("PubSub is not enabled for this node")
369
+
370
+ # Get the repository path
371
+ repo_path = c_str(self._repo_path.encode('utf-8'))
372
+
373
+ # Get topics
374
+ topics_ptr = libkubo.PubSubListTopics(repo_path)
375
+ if not topics_ptr:
376
+ return []
377
+
378
+ # Copy the string content before freeing the pointer
379
+ json_data = from_c_str(topics_ptr)
380
+
381
+ try:
382
+ # Free the memory allocated in Go
383
+ libkubo.FreeString(topics_ptr)
384
+ except Exception as e:
385
+ print(f"Warning: Failed to free memory: {e}")
386
+
387
+ try:
388
+ # Parse the JSON array
389
+ return json.loads(json_data)
390
+ except json.JSONDecodeError:
391
+ return []
392
+
393
+ def _enable_pubsub_config(self):
394
+ """Enable pubsub in the IPFS configuration."""
395
+ repo_path = c_str(self._repo_path.encode('utf-8'))
396
+ result = libkubo.PubSubEnable(repo_path)
397
+
398
+ if result < 0:
399
+ raise RuntimeError(f"Failed to enable pubsub: {result}")
400
+
401
+ def _pubsub_next_message(self, subscription_id: int) -> Optional[IPFSMessage]:
402
+ """
403
+ Get the next message from a subscription.
404
+
405
+ Args:
406
+ subscription_id: The subscription ID.
407
+
408
+ Returns:
409
+ IPFSMessage or None: The next message, or None if no message is available.
410
+ """
411
+ # sub_id = ctypes.c_longlong(subscription_id)
412
+
413
+ # Get message as JSON string
414
+ message_ptr = libkubo.PubSubNextMessage(subscription_id)
415
+ if not message_ptr:
416
+ return None
417
+
418
+ # Copy the string content before freeing the pointer
419
+ json_data = from_c_str(message_ptr)
420
+
421
+ try:
422
+ # Free the memory allocated in Go
423
+ libkubo.FreeString(message_ptr)
424
+ except Exception as e:
425
+ print(f"Warning: Failed to free memory: {e}")
426
+
427
+ try:
428
+ # Parse the message
429
+ return IPFSMessage.from_json(json_data)
430
+ except Exception as e:
431
+ print(f"Warning: Failed to parse message: {e}")
432
+ return None
433
+
434
+ def _pubsub_unsubscribe(self, subscription_id: int) -> bool:
435
+ """
436
+ Unsubscribe from a topic.
437
+
438
+ Args:
439
+ subscription_id: The subscription ID.
440
+
441
+ Returns:
442
+ bool: True if successfully unsubscribed.
443
+ """
444
+ # sub_id = ctypes.c_longlong(subscription_id)
445
+ result = libkubo.PubSubUnsubscribe(subscription_id)
446
+
447
+ # Clean up local subscription tracking
448
+ to_remove = []
449
+ for topic, subscriptions in self._subscriptions.items():
450
+ for sub in list(subscriptions):
451
+ if sub.id == subscription_id:
452
+ subscriptions.remove(sub)
453
+ # If no more subscriptions for this topic, remove the topic
454
+ if not subscriptions:
455
+ to_remove.append(topic)
456
+
457
+ for topic in to_remove:
458
+ del self._subscriptions[topic]
459
+
460
+ return result == 0
461
+
462
+ def terminate(self):
463
+ # Close all active subscriptions
464
+ for topic, subscriptions in list(self._subscriptions.items()):
465
+ for sub in list(subscriptions):
466
+ try:
467
+ sub.close()
468
+ except Exception as e:
469
+ print(f"Warning: Error closing subscription: {e}")
470
+
471
+ self._subscriptions.clear()
472
+
473
+ def __del__(self):
474
+ self.terminate()