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_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
+ }