hippius 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.
- hippius-0.1.0.dist-info/METADATA +475 -0
- hippius-0.1.0.dist-info/RECORD +9 -0
- hippius-0.1.0.dist-info/WHEEL +4 -0
- hippius-0.1.0.dist-info/entry_points.txt +4 -0
- hippius_sdk/__init__.py +11 -0
- hippius_sdk/cli.py +658 -0
- hippius_sdk/client.py +246 -0
- hippius_sdk/ipfs.py +985 -0
- hippius_sdk/substrate.py +688 -0
hippius_sdk/ipfs.py
ADDED
@@ -0,0 +1,985 @@
|
|
1
|
+
"""
|
2
|
+
IPFS operations for the Hippius SDK.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import json
|
7
|
+
import requests
|
8
|
+
import base64
|
9
|
+
import time
|
10
|
+
import tempfile
|
11
|
+
from typing import Dict, Any, Optional, Union, List
|
12
|
+
import ipfshttpclient
|
13
|
+
from dotenv import load_dotenv
|
14
|
+
|
15
|
+
# Import PyNaCl for encryption
|
16
|
+
try:
|
17
|
+
import nacl.secret
|
18
|
+
import nacl.utils
|
19
|
+
|
20
|
+
ENCRYPTION_AVAILABLE = True
|
21
|
+
except ImportError:
|
22
|
+
ENCRYPTION_AVAILABLE = False
|
23
|
+
|
24
|
+
|
25
|
+
class IPFSClient:
|
26
|
+
"""Client for interacting with IPFS."""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
gateway: str = "https://ipfs.io",
|
31
|
+
api_url: Optional[str] = "https://relay-fr.hippius.network",
|
32
|
+
encrypt_by_default: Optional[bool] = None,
|
33
|
+
encryption_key: Optional[bytes] = None,
|
34
|
+
):
|
35
|
+
"""
|
36
|
+
Initialize the IPFS client.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
gateway: IPFS gateway URL for downloading content
|
40
|
+
api_url: IPFS API URL for uploading content. Defaults to Hippius relay node.
|
41
|
+
Set to None to try to connect to a local IPFS daemon.
|
42
|
+
encrypt_by_default: Whether to encrypt files by default (from .env if None)
|
43
|
+
encryption_key: Encryption key for NaCl secretbox (from .env if None)
|
44
|
+
"""
|
45
|
+
self.gateway = gateway.rstrip("/")
|
46
|
+
self.api_url = api_url
|
47
|
+
self.client = None
|
48
|
+
|
49
|
+
# Extract base URL from API URL for HTTP fallback
|
50
|
+
self.base_url = api_url
|
51
|
+
|
52
|
+
# Connect to IPFS daemon
|
53
|
+
if api_url:
|
54
|
+
try:
|
55
|
+
# Only attempt to use ipfshttpclient if the URL is in multiaddr format (starts with /)
|
56
|
+
if api_url.startswith("/"):
|
57
|
+
self.client = ipfshttpclient.connect(api_url)
|
58
|
+
else:
|
59
|
+
# For regular HTTP URLs, we'll use the HTTP API directly
|
60
|
+
print(f"Using HTTP API at {api_url} for IPFS operations")
|
61
|
+
except ipfshttpclient.exceptions.ConnectionError as e:
|
62
|
+
print(f"Warning: Could not connect to IPFS node at {api_url}: {e}")
|
63
|
+
print(f"Falling back to HTTP API for uploads")
|
64
|
+
# We'll use HTTP API fallback for uploads
|
65
|
+
try:
|
66
|
+
# Try to connect to local IPFS daemon as fallback
|
67
|
+
self.client = ipfshttpclient.connect()
|
68
|
+
except ipfshttpclient.exceptions.ConnectionError:
|
69
|
+
# No IPFS connection available, but HTTP API fallback will be used
|
70
|
+
pass
|
71
|
+
else:
|
72
|
+
try:
|
73
|
+
# Try to connect to local IPFS daemon
|
74
|
+
self.client = ipfshttpclient.connect()
|
75
|
+
except ipfshttpclient.exceptions.ConnectionError:
|
76
|
+
# No local IPFS daemon connection available
|
77
|
+
pass
|
78
|
+
|
79
|
+
# Initialize encryption settings
|
80
|
+
self._initialize_encryption(encrypt_by_default, encryption_key)
|
81
|
+
|
82
|
+
def _initialize_encryption(
|
83
|
+
self, encrypt_by_default: Optional[bool], encryption_key: Optional[bytes]
|
84
|
+
):
|
85
|
+
"""Initialize encryption settings from parameters or .env file."""
|
86
|
+
# Check if encryption is available
|
87
|
+
if not ENCRYPTION_AVAILABLE:
|
88
|
+
self.encryption_available = False
|
89
|
+
self.encrypt_by_default = False
|
90
|
+
self.encryption_key = None
|
91
|
+
return
|
92
|
+
|
93
|
+
# Load environment variables
|
94
|
+
load_dotenv()
|
95
|
+
|
96
|
+
# Set up encryption default from parameter or .env
|
97
|
+
self.encrypt_by_default = encrypt_by_default
|
98
|
+
if self.encrypt_by_default is None:
|
99
|
+
env_default = os.getenv("HIPPIUS_ENCRYPT_BY_DEFAULT", "false").lower()
|
100
|
+
self.encrypt_by_default = env_default in ("true", "1", "yes")
|
101
|
+
|
102
|
+
# Set up encryption key from parameter or .env
|
103
|
+
self.encryption_key = encryption_key
|
104
|
+
if self.encryption_key is None:
|
105
|
+
env_key = os.getenv("HIPPIUS_ENCRYPTION_KEY")
|
106
|
+
if env_key:
|
107
|
+
try:
|
108
|
+
self.encryption_key = base64.b64decode(env_key)
|
109
|
+
# Validate key length
|
110
|
+
if len(self.encryption_key) != nacl.secret.SecretBox.KEY_SIZE:
|
111
|
+
print(
|
112
|
+
f"Warning: Encryption key from .env has incorrect length. Expected {nacl.secret.SecretBox.KEY_SIZE} bytes, got {len(self.encryption_key)} bytes."
|
113
|
+
)
|
114
|
+
self.encryption_key = None
|
115
|
+
except Exception as e:
|
116
|
+
print(f"Warning: Failed to decode encryption key from .env: {e}")
|
117
|
+
self.encryption_key = None
|
118
|
+
|
119
|
+
# Check if we have a valid key and can encrypt
|
120
|
+
self.encryption_available = (
|
121
|
+
ENCRYPTION_AVAILABLE
|
122
|
+
and self.encryption_key is not None
|
123
|
+
and len(self.encryption_key) == nacl.secret.SecretBox.KEY_SIZE
|
124
|
+
)
|
125
|
+
|
126
|
+
# If encryption is requested but not available, warn the user
|
127
|
+
if self.encrypt_by_default and not self.encryption_available:
|
128
|
+
print(
|
129
|
+
f"Warning: Encryption requested but not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
130
|
+
)
|
131
|
+
|
132
|
+
def encrypt_data(self, data: bytes) -> bytes:
|
133
|
+
"""
|
134
|
+
Encrypt binary data using XSalsa20-Poly1305 (NaCl/libsodium).
|
135
|
+
|
136
|
+
Args:
|
137
|
+
data: Binary data to encrypt
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
bytes: Encrypted data
|
141
|
+
|
142
|
+
Raises:
|
143
|
+
ValueError: If encryption is not available
|
144
|
+
TypeError: If data is not bytes
|
145
|
+
"""
|
146
|
+
if not self.encryption_available:
|
147
|
+
raise ValueError(
|
148
|
+
"Encryption is not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
149
|
+
)
|
150
|
+
|
151
|
+
if not isinstance(data, bytes):
|
152
|
+
raise TypeError("Data must be bytes")
|
153
|
+
|
154
|
+
# Create a SecretBox with our key
|
155
|
+
box = nacl.secret.SecretBox(self.encryption_key)
|
156
|
+
|
157
|
+
# Encrypt the data (nonce is automatically generated and included in the output)
|
158
|
+
encrypted = box.encrypt(data)
|
159
|
+
return encrypted
|
160
|
+
|
161
|
+
def decrypt_data(self, encrypted_data: bytes) -> bytes:
|
162
|
+
"""
|
163
|
+
Decrypt data encrypted with encrypt_data.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
encrypted_data: Data encrypted with encrypt_data
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
bytes: Decrypted data
|
170
|
+
|
171
|
+
Raises:
|
172
|
+
ValueError: If decryption fails or encryption is not available
|
173
|
+
"""
|
174
|
+
if not self.encryption_available:
|
175
|
+
raise ValueError(
|
176
|
+
"Encryption is not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
177
|
+
)
|
178
|
+
|
179
|
+
# Create a SecretBox with our key
|
180
|
+
box = nacl.secret.SecretBox(self.encryption_key)
|
181
|
+
|
182
|
+
try:
|
183
|
+
# Decrypt the data
|
184
|
+
decrypted = box.decrypt(encrypted_data)
|
185
|
+
return decrypted
|
186
|
+
except Exception as e:
|
187
|
+
raise ValueError(
|
188
|
+
f"Decryption failed: {str(e)}. Incorrect key or corrupted data?"
|
189
|
+
)
|
190
|
+
|
191
|
+
def _upload_via_http_api(self, file_path: str, max_retries: int = 3) -> str:
|
192
|
+
"""
|
193
|
+
Upload a file to IPFS using the HTTP API.
|
194
|
+
|
195
|
+
This is a fallback method when ipfshttpclient is not available.
|
196
|
+
|
197
|
+
Args:
|
198
|
+
file_path: Path to the file to upload
|
199
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
200
|
+
|
201
|
+
Returns:
|
202
|
+
str: Content Identifier (CID) of the uploaded file
|
203
|
+
|
204
|
+
Raises:
|
205
|
+
ConnectionError: If the upload fails
|
206
|
+
"""
|
207
|
+
if not self.base_url:
|
208
|
+
raise ConnectionError("No IPFS API URL provided for HTTP upload")
|
209
|
+
|
210
|
+
# Retry logic
|
211
|
+
retries = 0
|
212
|
+
last_error = None
|
213
|
+
|
214
|
+
while retries < max_retries:
|
215
|
+
try:
|
216
|
+
# Show progress for large files
|
217
|
+
file_size = os.path.getsize(file_path)
|
218
|
+
if file_size > 1024 * 1024: # If file is larger than 1MB
|
219
|
+
print(f" Uploading {file_size/1024/1024:.2f} MB file...")
|
220
|
+
|
221
|
+
# Prepare the file for upload
|
222
|
+
with open(file_path, "rb") as file:
|
223
|
+
files = {
|
224
|
+
"file": (
|
225
|
+
os.path.basename(file_path),
|
226
|
+
file,
|
227
|
+
"application/octet-stream",
|
228
|
+
)
|
229
|
+
}
|
230
|
+
|
231
|
+
# Make HTTP POST request to the IPFS HTTP API with a timeout
|
232
|
+
print(
|
233
|
+
f" Sending request to {self.base_url}/api/v0/add... (attempt {retries+1}/{max_retries})"
|
234
|
+
)
|
235
|
+
upload_url = f"{self.base_url}/api/v0/add"
|
236
|
+
response = requests.post(
|
237
|
+
upload_url,
|
238
|
+
files=files,
|
239
|
+
timeout=120, # 2 minute timeout for uploads
|
240
|
+
)
|
241
|
+
response.raise_for_status()
|
242
|
+
|
243
|
+
# Parse the response JSON
|
244
|
+
result = response.json()
|
245
|
+
print(f" Upload successful! CID: {result['Hash']}")
|
246
|
+
return result["Hash"]
|
247
|
+
|
248
|
+
except (
|
249
|
+
requests.exceptions.Timeout,
|
250
|
+
requests.exceptions.ConnectionError,
|
251
|
+
requests.exceptions.RequestException,
|
252
|
+
) as e:
|
253
|
+
# Save the error and retry
|
254
|
+
last_error = e
|
255
|
+
retries += 1
|
256
|
+
wait_time = 2**retries # Exponential backoff: 2, 4, 8 seconds
|
257
|
+
print(f" Upload attempt {retries} failed: {str(e)}")
|
258
|
+
if retries < max_retries:
|
259
|
+
print(f" Retrying in {wait_time} seconds...")
|
260
|
+
time.sleep(wait_time)
|
261
|
+
except Exception as e:
|
262
|
+
# For other exceptions, don't retry
|
263
|
+
raise ConnectionError(f"Failed to upload file via HTTP API: {str(e)}")
|
264
|
+
|
265
|
+
# If we've exhausted all retries
|
266
|
+
if last_error:
|
267
|
+
error_type = type(last_error).__name__
|
268
|
+
if isinstance(last_error, requests.exceptions.Timeout):
|
269
|
+
raise ConnectionError(
|
270
|
+
f"Timeout when uploading to {self.base_url} after {max_retries} attempts. The server is not responding."
|
271
|
+
)
|
272
|
+
elif isinstance(last_error, requests.exceptions.ConnectionError):
|
273
|
+
raise ConnectionError(
|
274
|
+
f"Failed to connect to IPFS node at {self.base_url} after {max_retries} attempts: {str(last_error)}"
|
275
|
+
)
|
276
|
+
else:
|
277
|
+
raise ConnectionError(
|
278
|
+
f"Failed to upload file via HTTP API after {max_retries} attempts. Last error ({error_type}): {str(last_error)}"
|
279
|
+
)
|
280
|
+
|
281
|
+
# This should never happen, but just in case
|
282
|
+
raise ConnectionError(
|
283
|
+
f"Failed to upload file to {self.base_url} after {max_retries} attempts for unknown reasons."
|
284
|
+
)
|
285
|
+
|
286
|
+
def upload_file(
|
287
|
+
self,
|
288
|
+
file_path: str,
|
289
|
+
include_formatted_size: bool = True,
|
290
|
+
encrypt: Optional[bool] = None,
|
291
|
+
) -> Dict[str, Any]:
|
292
|
+
"""
|
293
|
+
Upload a file to IPFS with optional encryption.
|
294
|
+
|
295
|
+
Args:
|
296
|
+
file_path: Path to the file to upload
|
297
|
+
include_formatted_size: Whether to include formatted size in the result (default: True)
|
298
|
+
encrypt: Whether to encrypt the file (overrides default)
|
299
|
+
|
300
|
+
Returns:
|
301
|
+
Dict[str, Any]: Dictionary containing:
|
302
|
+
- cid: Content Identifier (CID) of the uploaded file
|
303
|
+
- filename: Name of the uploaded file
|
304
|
+
- size_bytes: Size of the file in bytes
|
305
|
+
- size_formatted: Human-readable file size (if include_formatted_size is True)
|
306
|
+
- encrypted: Whether the file was encrypted
|
307
|
+
|
308
|
+
Raises:
|
309
|
+
FileNotFoundError: If the file doesn't exist
|
310
|
+
ConnectionError: If no IPFS connection is available
|
311
|
+
ValueError: If encryption is requested but not available
|
312
|
+
"""
|
313
|
+
if not os.path.exists(file_path):
|
314
|
+
raise FileNotFoundError(f"File {file_path} not found")
|
315
|
+
|
316
|
+
# Determine if we should encrypt
|
317
|
+
should_encrypt = self.encrypt_by_default if encrypt is None else encrypt
|
318
|
+
|
319
|
+
# Check if encryption is available if requested
|
320
|
+
if should_encrypt and not self.encryption_available:
|
321
|
+
raise ValueError(
|
322
|
+
"Encryption requested but not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
323
|
+
)
|
324
|
+
|
325
|
+
# Get file info before upload
|
326
|
+
filename = os.path.basename(file_path)
|
327
|
+
size_bytes = os.path.getsize(file_path)
|
328
|
+
|
329
|
+
# If encryption is requested, encrypt the file first
|
330
|
+
temp_file_path = None
|
331
|
+
try:
|
332
|
+
if should_encrypt:
|
333
|
+
# Read the file content
|
334
|
+
with open(file_path, "rb") as f:
|
335
|
+
file_data = f.read()
|
336
|
+
|
337
|
+
# Encrypt the data
|
338
|
+
encrypted_data = self.encrypt_data(file_data)
|
339
|
+
|
340
|
+
# Create a temporary file for the encrypted data
|
341
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
342
|
+
temp_file_path = temp_file.name
|
343
|
+
temp_file.write(encrypted_data)
|
344
|
+
|
345
|
+
# Use the temporary file for upload
|
346
|
+
upload_path = temp_file_path
|
347
|
+
else:
|
348
|
+
# Use the original file for upload
|
349
|
+
upload_path = file_path
|
350
|
+
|
351
|
+
# Upload to IPFS
|
352
|
+
if self.client:
|
353
|
+
# Use IPFS client
|
354
|
+
result = self.client.add(upload_path)
|
355
|
+
cid = result["Hash"]
|
356
|
+
elif self.base_url:
|
357
|
+
# Fallback to using HTTP API
|
358
|
+
cid = self._upload_via_http_api(upload_path)
|
359
|
+
else:
|
360
|
+
# No connection or API URL available
|
361
|
+
raise ConnectionError(
|
362
|
+
"No IPFS connection available. Please provide a valid api_url or ensure a local IPFS daemon is running."
|
363
|
+
)
|
364
|
+
|
365
|
+
finally:
|
366
|
+
# Clean up temporary file if created
|
367
|
+
if temp_file_path and os.path.exists(temp_file_path):
|
368
|
+
os.unlink(temp_file_path)
|
369
|
+
|
370
|
+
# Format the result
|
371
|
+
result = {
|
372
|
+
"cid": cid,
|
373
|
+
"filename": filename,
|
374
|
+
"size_bytes": size_bytes,
|
375
|
+
"encrypted": should_encrypt,
|
376
|
+
}
|
377
|
+
|
378
|
+
# Add formatted size if requested
|
379
|
+
if include_formatted_size:
|
380
|
+
result["size_formatted"] = self.format_size(size_bytes)
|
381
|
+
|
382
|
+
return result
|
383
|
+
|
384
|
+
def upload_directory(
|
385
|
+
self,
|
386
|
+
dir_path: str,
|
387
|
+
include_formatted_size: bool = True,
|
388
|
+
encrypt: Optional[bool] = None,
|
389
|
+
) -> Dict[str, Any]:
|
390
|
+
"""
|
391
|
+
Upload a directory to IPFS with optional encryption of files.
|
392
|
+
|
393
|
+
Args:
|
394
|
+
dir_path: Path to the directory to upload
|
395
|
+
include_formatted_size: Whether to include formatted size in the result (default: True)
|
396
|
+
encrypt: Whether to encrypt files (overrides default)
|
397
|
+
|
398
|
+
Returns:
|
399
|
+
Dict[str, Any]: Dictionary containing:
|
400
|
+
- cid: Content Identifier (CID) of the uploaded directory
|
401
|
+
- dirname: Name of the uploaded directory
|
402
|
+
- file_count: Number of files in the directory
|
403
|
+
- total_size_bytes: Total size of all files in bytes
|
404
|
+
- size_formatted: Human-readable total size (if include_formatted_size is True)
|
405
|
+
- encrypted: Whether files were encrypted
|
406
|
+
|
407
|
+
Raises:
|
408
|
+
FileNotFoundError: If the directory doesn't exist
|
409
|
+
ConnectionError: If no IPFS connection is available
|
410
|
+
ValueError: If encryption is requested but not available
|
411
|
+
"""
|
412
|
+
if not os.path.isdir(dir_path):
|
413
|
+
raise FileNotFoundError(f"Directory {dir_path} not found")
|
414
|
+
|
415
|
+
# Determine if we should encrypt
|
416
|
+
should_encrypt = self.encrypt_by_default if encrypt is None else encrypt
|
417
|
+
|
418
|
+
# Check if encryption is available if requested
|
419
|
+
if should_encrypt and not self.encryption_available:
|
420
|
+
raise ValueError(
|
421
|
+
"Encryption requested but not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
422
|
+
)
|
423
|
+
|
424
|
+
# For encryption, we have to handle each file separately, so we'll use a different approach
|
425
|
+
if should_encrypt:
|
426
|
+
# Create a temporary directory for encrypted files
|
427
|
+
temp_dir = tempfile.mkdtemp()
|
428
|
+
try:
|
429
|
+
# Process each file in the directory
|
430
|
+
file_count = 0
|
431
|
+
total_size_bytes = 0
|
432
|
+
|
433
|
+
for root, _, files in os.walk(dir_path):
|
434
|
+
for file in files:
|
435
|
+
file_path = os.path.join(root, file)
|
436
|
+
rel_path = os.path.relpath(file_path, dir_path)
|
437
|
+
|
438
|
+
# Create the directory structure in the temp directory
|
439
|
+
temp_file_dir = os.path.dirname(
|
440
|
+
os.path.join(temp_dir, rel_path)
|
441
|
+
)
|
442
|
+
os.makedirs(temp_file_dir, exist_ok=True)
|
443
|
+
|
444
|
+
# Read and encrypt the file
|
445
|
+
with open(file_path, "rb") as f:
|
446
|
+
file_data = f.read()
|
447
|
+
|
448
|
+
encrypted_data = self.encrypt_data(file_data)
|
449
|
+
|
450
|
+
# Write the encrypted file to the temp directory
|
451
|
+
with open(os.path.join(temp_dir, rel_path), "wb") as f:
|
452
|
+
f.write(encrypted_data)
|
453
|
+
|
454
|
+
file_count += 1
|
455
|
+
total_size_bytes += os.path.getsize(file_path)
|
456
|
+
|
457
|
+
# Use temp_dir instead of dir_path for upload
|
458
|
+
if self.client:
|
459
|
+
result = self.client.add(temp_dir, recursive=True)
|
460
|
+
if isinstance(result, list):
|
461
|
+
cid = result[-1]["Hash"]
|
462
|
+
else:
|
463
|
+
cid = result["Hash"]
|
464
|
+
elif self.base_url:
|
465
|
+
cid = self._upload_directory_via_http_api(temp_dir)
|
466
|
+
else:
|
467
|
+
raise ConnectionError("No IPFS connection available")
|
468
|
+
finally:
|
469
|
+
# Clean up the temporary directory
|
470
|
+
import shutil
|
471
|
+
|
472
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
473
|
+
else:
|
474
|
+
# Get directory info
|
475
|
+
dirname = os.path.basename(dir_path)
|
476
|
+
file_count = 0
|
477
|
+
total_size_bytes = 0
|
478
|
+
|
479
|
+
# Calculate directory size and file count
|
480
|
+
for root, _, files in os.walk(dir_path):
|
481
|
+
for file in files:
|
482
|
+
file_path = os.path.join(root, file)
|
483
|
+
try:
|
484
|
+
total_size_bytes += os.path.getsize(file_path)
|
485
|
+
file_count += 1
|
486
|
+
except (OSError, IOError):
|
487
|
+
pass
|
488
|
+
|
489
|
+
# Upload to IPFS
|
490
|
+
if self.client:
|
491
|
+
# Use IPFS client
|
492
|
+
result = self.client.add(dir_path, recursive=True)
|
493
|
+
if isinstance(result, list):
|
494
|
+
# Get the last item, which should be the directory itself
|
495
|
+
cid = result[-1]["Hash"]
|
496
|
+
else:
|
497
|
+
cid = result["Hash"]
|
498
|
+
elif self.base_url:
|
499
|
+
# Fallback to using HTTP API
|
500
|
+
cid = self._upload_directory_via_http_api(dir_path)
|
501
|
+
else:
|
502
|
+
# No connection or API URL available
|
503
|
+
raise ConnectionError(
|
504
|
+
"No IPFS connection available. Please provide a valid api_url or ensure a local IPFS daemon is running."
|
505
|
+
)
|
506
|
+
|
507
|
+
# Get dirname in case it wasn't set (for encryption path)
|
508
|
+
dirname = os.path.basename(dir_path)
|
509
|
+
|
510
|
+
# Format the result
|
511
|
+
result = {
|
512
|
+
"cid": cid,
|
513
|
+
"dirname": dirname,
|
514
|
+
"file_count": file_count,
|
515
|
+
"total_size_bytes": total_size_bytes,
|
516
|
+
"encrypted": should_encrypt,
|
517
|
+
}
|
518
|
+
|
519
|
+
# Add formatted size if requested
|
520
|
+
if include_formatted_size:
|
521
|
+
result["size_formatted"] = self.format_size(total_size_bytes)
|
522
|
+
|
523
|
+
return result
|
524
|
+
|
525
|
+
def _upload_directory_via_http_api(
|
526
|
+
self, dir_path: str, max_retries: int = 3
|
527
|
+
) -> str:
|
528
|
+
"""
|
529
|
+
Upload a directory to IPFS using the HTTP API.
|
530
|
+
|
531
|
+
This is a limited implementation and may not support all directory features.
|
532
|
+
|
533
|
+
Args:
|
534
|
+
dir_path: Path to the directory to upload
|
535
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
str: Content Identifier (CID) of the uploaded directory
|
539
|
+
|
540
|
+
Raises:
|
541
|
+
ConnectionError: If the upload fails
|
542
|
+
"""
|
543
|
+
if not self.base_url:
|
544
|
+
raise ConnectionError("No IPFS API URL provided for HTTP upload")
|
545
|
+
|
546
|
+
# Retry logic
|
547
|
+
retries = 0
|
548
|
+
last_error = None
|
549
|
+
|
550
|
+
while retries < max_retries:
|
551
|
+
try:
|
552
|
+
# This is a simplified approach - we'll upload the directory with recursive flag
|
553
|
+
files = []
|
554
|
+
|
555
|
+
print(f" Preparing directory contents for upload...")
|
556
|
+
# Collect all files in the directory
|
557
|
+
for root, _, filenames in os.walk(dir_path):
|
558
|
+
for filename in filenames:
|
559
|
+
file_path = os.path.join(root, filename)
|
560
|
+
rel_path = os.path.relpath(file_path, dir_path)
|
561
|
+
|
562
|
+
with open(file_path, "rb") as f:
|
563
|
+
file_content = f.read()
|
564
|
+
|
565
|
+
# Add the file to the multipart request
|
566
|
+
files.append(
|
567
|
+
(
|
568
|
+
"file",
|
569
|
+
(rel_path, file_content, "application/octet-stream"),
|
570
|
+
)
|
571
|
+
)
|
572
|
+
|
573
|
+
# Create a request with the directory flag
|
574
|
+
upload_url = f"{self.base_url}/api/v0/add?recursive=true&wrap-with-directory=true"
|
575
|
+
|
576
|
+
print(
|
577
|
+
f" Sending directory upload request to {self.base_url}/api/v0/add... (attempt {retries+1}/{max_retries})"
|
578
|
+
)
|
579
|
+
print(f" Uploading {len(files)} files...")
|
580
|
+
|
581
|
+
# Make HTTP POST request with timeout
|
582
|
+
response = requests.post(
|
583
|
+
upload_url,
|
584
|
+
files=files,
|
585
|
+
timeout=300, # 5 minute timeout for directory uploads
|
586
|
+
)
|
587
|
+
response.raise_for_status()
|
588
|
+
|
589
|
+
# The IPFS API returns a JSON object for each file, one per line
|
590
|
+
# The last one should be the directory itself
|
591
|
+
lines = response.text.strip().split("\n")
|
592
|
+
if not lines:
|
593
|
+
raise ConnectionError("Empty response from IPFS API")
|
594
|
+
|
595
|
+
last_item = json.loads(lines[-1])
|
596
|
+
print(f" Directory upload successful! CID: {last_item['Hash']}")
|
597
|
+
return last_item["Hash"]
|
598
|
+
|
599
|
+
except (
|
600
|
+
requests.exceptions.Timeout,
|
601
|
+
requests.exceptions.ConnectionError,
|
602
|
+
requests.exceptions.RequestException,
|
603
|
+
) as e:
|
604
|
+
# Save the error and retry
|
605
|
+
last_error = e
|
606
|
+
retries += 1
|
607
|
+
wait_time = 2**retries # Exponential backoff: 2, 4, 8 seconds
|
608
|
+
print(f" Upload attempt {retries} failed: {str(e)}")
|
609
|
+
if retries < max_retries:
|
610
|
+
print(f" Retrying in {wait_time} seconds...")
|
611
|
+
time.sleep(wait_time)
|
612
|
+
except Exception as e:
|
613
|
+
# For other exceptions, don't retry
|
614
|
+
raise ConnectionError(
|
615
|
+
f"Failed to upload directory via HTTP API: {str(e)}"
|
616
|
+
)
|
617
|
+
|
618
|
+
# If we've exhausted all retries
|
619
|
+
if last_error:
|
620
|
+
error_type = type(last_error).__name__
|
621
|
+
if isinstance(last_error, requests.exceptions.Timeout):
|
622
|
+
raise ConnectionError(
|
623
|
+
f"Timeout when uploading directory to {self.base_url} after {max_retries} attempts. The server is not responding."
|
624
|
+
)
|
625
|
+
elif isinstance(last_error, requests.exceptions.ConnectionError):
|
626
|
+
raise ConnectionError(
|
627
|
+
f"Failed to connect to IPFS node at {self.base_url} after {max_retries} attempts: {str(last_error)}"
|
628
|
+
)
|
629
|
+
else:
|
630
|
+
raise ConnectionError(
|
631
|
+
f"Failed to upload directory via HTTP API after {max_retries} attempts. Last error ({error_type}): {str(last_error)}"
|
632
|
+
)
|
633
|
+
|
634
|
+
# This should never happen, but just in case
|
635
|
+
raise ConnectionError(
|
636
|
+
f"Failed to upload directory to {self.base_url} after {max_retries} attempts for unknown reasons."
|
637
|
+
)
|
638
|
+
|
639
|
+
def format_size(self, size_bytes: int) -> str:
|
640
|
+
"""
|
641
|
+
Format a size in bytes to a human-readable string.
|
642
|
+
|
643
|
+
Args:
|
644
|
+
size_bytes: Size in bytes
|
645
|
+
|
646
|
+
Returns:
|
647
|
+
str: Human-readable size string (e.g., '1.23 MB', '456.78 KB')
|
648
|
+
"""
|
649
|
+
if size_bytes >= 1024 * 1024 * 1024:
|
650
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
651
|
+
elif size_bytes >= 1024 * 1024:
|
652
|
+
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
653
|
+
elif size_bytes >= 1024:
|
654
|
+
return f"{size_bytes / 1024:.2f} KB"
|
655
|
+
else:
|
656
|
+
return f"{size_bytes} bytes"
|
657
|
+
|
658
|
+
def format_cid(self, cid: str) -> str:
|
659
|
+
"""
|
660
|
+
Format a CID for display.
|
661
|
+
|
662
|
+
This method handles both regular CIDs and hex-encoded CIDs.
|
663
|
+
|
664
|
+
Args:
|
665
|
+
cid: Content Identifier (CID) to format
|
666
|
+
|
667
|
+
Returns:
|
668
|
+
str: Formatted CID string
|
669
|
+
"""
|
670
|
+
# If it already looks like a proper CID, return it as is
|
671
|
+
if cid.startswith(("Qm", "bafy", "bafk", "bafyb", "bafzb", "b")):
|
672
|
+
return cid
|
673
|
+
|
674
|
+
# Check if it's a hex string
|
675
|
+
if all(c in "0123456789abcdefABCDEF" for c in cid):
|
676
|
+
# First try the special case where the hex string is actually ASCII encoded
|
677
|
+
try:
|
678
|
+
# Try to decode the hex as ASCII characters
|
679
|
+
hex_bytes = bytes.fromhex(cid)
|
680
|
+
ascii_str = hex_bytes.decode("ascii")
|
681
|
+
|
682
|
+
# If the decoded string starts with a valid CID prefix, return it
|
683
|
+
if ascii_str.startswith(("Qm", "bafy", "bafk", "bafyb", "bafzb", "b")):
|
684
|
+
return ascii_str
|
685
|
+
except Exception:
|
686
|
+
pass
|
687
|
+
|
688
|
+
# If the above doesn't work, try the standard CID decoding
|
689
|
+
try:
|
690
|
+
import base58
|
691
|
+
import binascii
|
692
|
+
|
693
|
+
# Try to decode hex to binary then to base58 for CIDv0
|
694
|
+
try:
|
695
|
+
binary_data = binascii.unhexlify(cid)
|
696
|
+
if (
|
697
|
+
len(binary_data) > 2
|
698
|
+
and binary_data[0] == 0x12
|
699
|
+
and binary_data[1] == 0x20
|
700
|
+
):
|
701
|
+
# This looks like a CIDv0 (Qm...)
|
702
|
+
decoded_cid = base58.b58encode(binary_data).decode("utf-8")
|
703
|
+
return decoded_cid
|
704
|
+
except Exception:
|
705
|
+
pass
|
706
|
+
|
707
|
+
# If not successful, just return hex with 0x prefix as fallback
|
708
|
+
return f"0x{cid}"
|
709
|
+
except ImportError:
|
710
|
+
# If base58 is not available, return hex with prefix
|
711
|
+
return f"0x{cid}"
|
712
|
+
|
713
|
+
# Default case - return as is
|
714
|
+
return cid
|
715
|
+
|
716
|
+
def download_file(
|
717
|
+
self, cid: str, output_path: str, decrypt: Optional[bool] = None
|
718
|
+
) -> Dict[str, Any]:
|
719
|
+
"""
|
720
|
+
Download a file from IPFS with optional decryption.
|
721
|
+
|
722
|
+
Args:
|
723
|
+
cid: Content Identifier (CID) of the file to download
|
724
|
+
output_path: Path where the downloaded file will be saved
|
725
|
+
decrypt: Whether to decrypt the file (overrides default)
|
726
|
+
|
727
|
+
Returns:
|
728
|
+
Dict[str, Any]: Dictionary containing download results:
|
729
|
+
- success: Whether the download was successful
|
730
|
+
- output_path: Path where the file was saved
|
731
|
+
- size_bytes: Size of the downloaded file in bytes
|
732
|
+
- size_formatted: Human-readable file size
|
733
|
+
- elapsed_seconds: Time taken for the download in seconds
|
734
|
+
- decrypted: Whether the file was decrypted
|
735
|
+
|
736
|
+
Raises:
|
737
|
+
requests.RequestException: If the download fails
|
738
|
+
ValueError: If decryption is requested but fails
|
739
|
+
"""
|
740
|
+
start_time = time.time()
|
741
|
+
|
742
|
+
# Determine if we should decrypt
|
743
|
+
should_decrypt = self.encrypt_by_default if decrypt is None else decrypt
|
744
|
+
|
745
|
+
# Check if decryption is available if requested
|
746
|
+
if should_decrypt and not self.encryption_available:
|
747
|
+
raise ValueError(
|
748
|
+
"Decryption requested but not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
749
|
+
)
|
750
|
+
|
751
|
+
# Create a temporary file if we'll be decrypting
|
752
|
+
temp_file_path = None
|
753
|
+
try:
|
754
|
+
if should_decrypt:
|
755
|
+
# Create a temporary file for the encrypted data
|
756
|
+
temp_file = tempfile.NamedTemporaryFile(delete=False)
|
757
|
+
temp_file_path = temp_file.name
|
758
|
+
temp_file.close()
|
759
|
+
download_path = temp_file_path
|
760
|
+
else:
|
761
|
+
download_path = output_path
|
762
|
+
|
763
|
+
# Download the file
|
764
|
+
url = f"{self.gateway}/ipfs/{cid}"
|
765
|
+
response = requests.get(url, stream=True)
|
766
|
+
response.raise_for_status()
|
767
|
+
|
768
|
+
os.makedirs(os.path.dirname(os.path.abspath(download_path)), exist_ok=True)
|
769
|
+
|
770
|
+
with open(download_path, "wb") as f:
|
771
|
+
for chunk in response.iter_content(chunk_size=8192):
|
772
|
+
f.write(chunk)
|
773
|
+
|
774
|
+
# Decrypt if needed
|
775
|
+
if should_decrypt:
|
776
|
+
try:
|
777
|
+
# Read the encrypted data
|
778
|
+
with open(temp_file_path, "rb") as f:
|
779
|
+
encrypted_data = f.read()
|
780
|
+
|
781
|
+
# Decrypt the data
|
782
|
+
decrypted_data = self.decrypt_data(encrypted_data)
|
783
|
+
|
784
|
+
# Write the decrypted data to the output path
|
785
|
+
os.makedirs(
|
786
|
+
os.path.dirname(os.path.abspath(output_path)), exist_ok=True
|
787
|
+
)
|
788
|
+
with open(output_path, "wb") as f:
|
789
|
+
f.write(decrypted_data)
|
790
|
+
|
791
|
+
# Use output_path for size measurement
|
792
|
+
file_size_bytes = len(decrypted_data)
|
793
|
+
except Exception as e:
|
794
|
+
raise ValueError(f"Failed to decrypt file: {str(e)}")
|
795
|
+
else:
|
796
|
+
file_size_bytes = os.path.getsize(output_path)
|
797
|
+
|
798
|
+
elapsed_time = time.time() - start_time
|
799
|
+
|
800
|
+
return {
|
801
|
+
"success": True,
|
802
|
+
"output_path": output_path,
|
803
|
+
"size_bytes": file_size_bytes,
|
804
|
+
"size_formatted": self.format_size(file_size_bytes),
|
805
|
+
"elapsed_seconds": round(elapsed_time, 2),
|
806
|
+
"decrypted": should_decrypt,
|
807
|
+
}
|
808
|
+
|
809
|
+
finally:
|
810
|
+
# Clean up temporary file if created
|
811
|
+
if temp_file_path and os.path.exists(temp_file_path):
|
812
|
+
os.unlink(temp_file_path)
|
813
|
+
|
814
|
+
def cat(
|
815
|
+
self,
|
816
|
+
cid: str,
|
817
|
+
max_display_bytes: int = 1024,
|
818
|
+
format_output: bool = True,
|
819
|
+
decrypt: Optional[bool] = None,
|
820
|
+
) -> Dict[str, Any]:
|
821
|
+
"""
|
822
|
+
Get the content of a file from IPFS with optional decryption.
|
823
|
+
|
824
|
+
Args:
|
825
|
+
cid: Content Identifier (CID) of the file
|
826
|
+
max_display_bytes: Maximum number of bytes to include in the preview (default: 1024)
|
827
|
+
format_output: Whether to attempt to decode the content as text (default: True)
|
828
|
+
decrypt: Whether to decrypt the file (overrides default)
|
829
|
+
|
830
|
+
Returns:
|
831
|
+
Dict[str, Any]: Dictionary containing:
|
832
|
+
- content: Complete binary content of the file
|
833
|
+
- size_bytes: Size of the content in bytes
|
834
|
+
- size_formatted: Human-readable size
|
835
|
+
- preview: First part of the content (limited by max_display_bytes)
|
836
|
+
- is_text: Whether the content seems to be text
|
837
|
+
- text_preview: Text preview if is_text is True (up to max_display_bytes)
|
838
|
+
- hex_preview: Hex preview if is_text is False (up to max_display_bytes)
|
839
|
+
- decrypted: Whether the file was decrypted
|
840
|
+
|
841
|
+
Raises:
|
842
|
+
requests.RequestException: If fetching the content fails
|
843
|
+
ValueError: If decryption is requested but fails
|
844
|
+
"""
|
845
|
+
# Determine if we should decrypt
|
846
|
+
should_decrypt = self.encrypt_by_default if decrypt is None else decrypt
|
847
|
+
|
848
|
+
# Check if decryption is available if requested
|
849
|
+
if should_decrypt and not self.encryption_available:
|
850
|
+
raise ValueError(
|
851
|
+
"Decryption requested but not available. Check that PyNaCl is installed and a valid encryption key is provided."
|
852
|
+
)
|
853
|
+
|
854
|
+
# Get the content
|
855
|
+
if self.client:
|
856
|
+
content = self.client.cat(cid)
|
857
|
+
else:
|
858
|
+
url = f"{self.gateway}/ipfs/{cid}"
|
859
|
+
response = requests.get(url)
|
860
|
+
response.raise_for_status()
|
861
|
+
content = response.content
|
862
|
+
|
863
|
+
# Decrypt if needed
|
864
|
+
if should_decrypt:
|
865
|
+
try:
|
866
|
+
content = self.decrypt_data(content)
|
867
|
+
except Exception as e:
|
868
|
+
raise ValueError(f"Failed to decrypt file: {str(e)}")
|
869
|
+
|
870
|
+
size_bytes = len(content)
|
871
|
+
|
872
|
+
result = {
|
873
|
+
"content": content,
|
874
|
+
"size_bytes": size_bytes,
|
875
|
+
"size_formatted": self.format_size(size_bytes),
|
876
|
+
"decrypted": should_decrypt,
|
877
|
+
}
|
878
|
+
|
879
|
+
# Add preview
|
880
|
+
if format_output:
|
881
|
+
# Limit preview size
|
882
|
+
preview = content[:max_display_bytes]
|
883
|
+
result["preview"] = preview
|
884
|
+
|
885
|
+
# Try to decode as text
|
886
|
+
try:
|
887
|
+
text_preview = preview.decode("utf-8")
|
888
|
+
result["is_text"] = True
|
889
|
+
result["text_preview"] = text_preview
|
890
|
+
except UnicodeDecodeError:
|
891
|
+
result["is_text"] = False
|
892
|
+
result["hex_preview"] = preview.hex()
|
893
|
+
|
894
|
+
return result
|
895
|
+
|
896
|
+
def exists(self, cid: str) -> Dict[str, Any]:
|
897
|
+
"""
|
898
|
+
Check if a CID exists on IPFS.
|
899
|
+
|
900
|
+
Args:
|
901
|
+
cid: Content Identifier (CID) to check
|
902
|
+
|
903
|
+
Returns:
|
904
|
+
Dict[str, Any]: Dictionary containing:
|
905
|
+
- exists: Boolean indicating if the CID exists
|
906
|
+
- cid: The CID that was checked
|
907
|
+
- formatted_cid: Formatted version of the CID
|
908
|
+
- gateway_url: URL to access the content if it exists
|
909
|
+
"""
|
910
|
+
formatted_cid = self.format_cid(cid)
|
911
|
+
gateway_url = f"{self.gateway}/ipfs/{cid}"
|
912
|
+
|
913
|
+
try:
|
914
|
+
if self.client:
|
915
|
+
# We'll try to get the file stats
|
916
|
+
self.client.ls(cid)
|
917
|
+
exists = True
|
918
|
+
else:
|
919
|
+
# Try to access through gateway
|
920
|
+
url = f"{self.gateway}/ipfs/{cid}"
|
921
|
+
response = requests.head(url)
|
922
|
+
exists = response.status_code == 200
|
923
|
+
except (ipfshttpclient.exceptions.ErrorResponse, requests.RequestException):
|
924
|
+
exists = False
|
925
|
+
|
926
|
+
return {
|
927
|
+
"exists": exists,
|
928
|
+
"cid": cid,
|
929
|
+
"formatted_cid": formatted_cid,
|
930
|
+
"gateway_url": gateway_url if exists else None,
|
931
|
+
}
|
932
|
+
|
933
|
+
def pin(self, cid: str) -> Dict[str, Any]:
|
934
|
+
"""
|
935
|
+
Pin a CID to IPFS to keep it available.
|
936
|
+
|
937
|
+
Args:
|
938
|
+
cid: Content Identifier (CID) to pin
|
939
|
+
|
940
|
+
Returns:
|
941
|
+
Dict[str, Any]: Dictionary containing:
|
942
|
+
- success: Boolean indicating if pinning was successful
|
943
|
+
- cid: The CID that was pinned
|
944
|
+
- formatted_cid: Formatted version of the CID
|
945
|
+
- message: Status message
|
946
|
+
|
947
|
+
Raises:
|
948
|
+
ConnectionError: If no IPFS connection is available
|
949
|
+
"""
|
950
|
+
formatted_cid = self.format_cid(cid)
|
951
|
+
|
952
|
+
if not self.client and self.base_url:
|
953
|
+
# Try using HTTP API for pinning
|
954
|
+
try:
|
955
|
+
url = f"{self.base_url}/api/v0/pin/add?arg={cid}"
|
956
|
+
response = requests.post(url)
|
957
|
+
response.raise_for_status()
|
958
|
+
success = True
|
959
|
+
message = "Successfully pinned via HTTP API"
|
960
|
+
except requests.RequestException as e:
|
961
|
+
success = False
|
962
|
+
message = f"Failed to pin: {str(e)}"
|
963
|
+
elif not self.client:
|
964
|
+
raise ConnectionError(
|
965
|
+
"No IPFS connection available. Please provide a valid api_url or ensure a local IPFS daemon is running."
|
966
|
+
)
|
967
|
+
|
968
|
+
try:
|
969
|
+
if self.client:
|
970
|
+
self.client.pin.add(cid)
|
971
|
+
success = True
|
972
|
+
message = "Successfully pinned"
|
973
|
+
else:
|
974
|
+
success = False
|
975
|
+
message = "No IPFS client available"
|
976
|
+
except ipfshttpclient.exceptions.ErrorResponse as e:
|
977
|
+
success = False
|
978
|
+
message = f"Failed to pin: {str(e)}"
|
979
|
+
|
980
|
+
return {
|
981
|
+
"success": success,
|
982
|
+
"cid": cid,
|
983
|
+
"formatted_cid": formatted_cid,
|
984
|
+
"message": message,
|
985
|
+
}
|