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.
- ipfs_node/__init__.py +4 -0
- ipfs_node/ipfs_files.py +233 -0
- ipfs_node/ipfs_node.py +230 -0
- ipfs_node/ipfs_peers.py +71 -0
- ipfs_node/ipfs_pubsub.py +474 -0
- ipfs_node/ipfs_tunnels.py +263 -0
- ipfs_node/libkubo/libkubo_linux_x86_64.h +237 -0
- ipfs_node/libkubo/libkubo_linux_x86_64.so +0 -0
- ipfs_node/utils/__init__.py +3 -0
- ipfs_node/utils/cid_utils.py +55 -0
- ipfs_node/utils/peer_utils.py +62 -0
- ipfs_node-0.1.6.dist-info/LICENSE +21 -0
- ipfs_node-0.1.6.dist-info/METADATA +134 -0
- ipfs_node-0.1.6.dist-info/RECORD +18 -0
- ipfs_node-0.1.6.dist-info/WHEEL +5 -0
- ipfs_node-0.1.6.dist-info/top_level.txt +2 -0
- libkubo/__init__.py +1 -0
- libkubo/libkubo_loader.py +45 -0
ipfs_node/ipfs_pubsub.py
ADDED
|
@@ -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()
|