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/cli.py ADDED
@@ -0,0 +1,658 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command Line Interface tools for Hippius SDK.
4
+
5
+ This module provides CLI tools for working with the Hippius SDK, including
6
+ utilities for encryption key generation, file operations, and marketplace interactions.
7
+ """
8
+
9
+ import base64
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import time
14
+ from typing import Optional, List
15
+
16
+ # Import SDK components
17
+ from hippius_sdk import HippiusClient
18
+ from hippius_sdk.substrate import FileInput
19
+ from dotenv import load_dotenv
20
+
21
+ try:
22
+ import nacl.utils
23
+ import nacl.secret
24
+ except ImportError:
25
+ ENCRYPTION_AVAILABLE = False
26
+ else:
27
+ ENCRYPTION_AVAILABLE = True
28
+
29
+ # Load environment variables
30
+ load_dotenv()
31
+
32
+
33
+ def generate_key():
34
+ """Generate a random encryption key for NaCl secretbox."""
35
+ if not ENCRYPTION_AVAILABLE:
36
+ print(
37
+ "Error: PyNaCl is required for encryption. Install it with: pip install pynacl"
38
+ )
39
+ sys.exit(1)
40
+
41
+ # Generate a random key
42
+ key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
43
+
44
+ # Encode to base64 for .env file
45
+ encoded_key = base64.b64encode(key).decode()
46
+
47
+ return encoded_key
48
+
49
+
50
+ def key_generation_cli():
51
+ """CLI entry point for encryption key generation."""
52
+ parser = argparse.ArgumentParser(
53
+ description="Generate a secure encryption key for Hippius SDK"
54
+ )
55
+ parser.add_argument("--copy", action="store_true", help="Copy the key to clipboard")
56
+ args = parser.parse_args()
57
+
58
+ # Generate the key
59
+ encoded_key = generate_key()
60
+
61
+ # Copy to clipboard if requested
62
+ if args.copy:
63
+ try:
64
+ import pyperclip
65
+
66
+ pyperclip.copy(encoded_key)
67
+ print("Key copied to clipboard!")
68
+ except ImportError:
69
+ print(
70
+ "Warning: Could not copy to clipboard. Install pyperclip with: pip install pyperclip"
71
+ )
72
+
73
+ # Print instructions
74
+ print("\nGenerated a new encryption key for Hippius SDK")
75
+ print(f"Key: {encoded_key}")
76
+ print("\nAdd this to your .env file:")
77
+ print(f"HIPPIUS_ENCRYPTION_KEY={encoded_key}")
78
+ print("\nOr configure it in your code:")
79
+ print("import base64")
80
+ print(f'encryption_key = base64.b64decode("{encoded_key}")')
81
+ print(
82
+ "client = HippiusClient(encrypt_by_default=True, encryption_key=encryption_key)"
83
+ )
84
+
85
+
86
+ def create_client(args):
87
+ """Create a HippiusClient instance from command line arguments."""
88
+ # Process encryption flags
89
+ encrypt = None
90
+ if hasattr(args, "encrypt") and args.encrypt:
91
+ encrypt = True
92
+ elif hasattr(args, "no_encrypt") and args.no_encrypt:
93
+ encrypt = False
94
+
95
+ decrypt = None
96
+ if hasattr(args, "decrypt") and args.decrypt:
97
+ decrypt = True
98
+ elif hasattr(args, "no_decrypt") and args.no_decrypt:
99
+ decrypt = False
100
+
101
+ # Process encryption key if provided
102
+ encryption_key = None
103
+ if hasattr(args, "encryption_key") and args.encryption_key:
104
+ try:
105
+ encryption_key = base64.b64decode(args.encryption_key)
106
+ if args.verbose:
107
+ print(f"Using provided encryption key")
108
+ except Exception as e:
109
+ print(f"Warning: Could not decode encryption key: {e}")
110
+ print(f"Using default encryption key from .env if available")
111
+
112
+ # Initialize client with provided gateway and API URL
113
+ client = HippiusClient(
114
+ ipfs_gateway=args.gateway,
115
+ ipfs_api_url="http://localhost:5001" if args.local_ipfs else args.api_url,
116
+ substrate_url=args.substrate_url,
117
+ encrypt_by_default=encrypt,
118
+ encryption_key=encryption_key,
119
+ )
120
+
121
+ return client, encrypt, decrypt
122
+
123
+
124
+ def handle_download(client, cid, output_path, decrypt=None):
125
+ """Handle the download command"""
126
+ print(f"Downloading {cid} to {output_path}...")
127
+
128
+ # Use the enhanced download method which returns formatted information
129
+ result = client.download_file(cid, output_path, decrypt=decrypt)
130
+
131
+ print(f"Download successful in {result['elapsed_seconds']} seconds!")
132
+ print(f"Saved to: {result['output_path']}")
133
+ print(f"Size: {result['size_bytes']:,} bytes ({result['size_formatted']})")
134
+
135
+ if result.get("decrypted"):
136
+ print("File was decrypted during download")
137
+
138
+ return 0
139
+
140
+
141
+ def handle_exists(client, cid):
142
+ """Handle the exists command"""
143
+ print(f"Checking if CID {cid} exists on IPFS...")
144
+ result = client.exists(cid)
145
+
146
+ # Use the formatted CID from the result
147
+ formatted_cid = result["formatted_cid"]
148
+ exists = result["exists"]
149
+
150
+ print(f"CID {formatted_cid} exists: {exists}")
151
+
152
+ if exists and result.get("gateway_url"):
153
+ print(f"Gateway URL: {result['gateway_url']}")
154
+ print("\nTo download this file, you can run:")
155
+ print(f" hippius download {formatted_cid} <output_path>")
156
+
157
+ return 0
158
+
159
+
160
+ def handle_cat(client, cid, max_size, decrypt=None):
161
+ """Handle the cat command"""
162
+ print(f"Retrieving content of CID {cid}...")
163
+ try:
164
+ # Use the enhanced cat method with formatting
165
+ result = client.cat(cid, max_display_bytes=max_size, decrypt=decrypt)
166
+
167
+ # Display file information
168
+ print(
169
+ f"Content size: {result['size_bytes']:,} bytes ({result['size_formatted']})"
170
+ )
171
+
172
+ if result.get("decrypted"):
173
+ print("Content was decrypted")
174
+
175
+ # Display content based on type
176
+ if result["is_text"]:
177
+ print("\nContent (text):")
178
+ print(result["text_preview"])
179
+ if result["size_bytes"] > max_size:
180
+ print(
181
+ f"\n... (showing first {max_size} bytes of {result['size_bytes']} total) ..."
182
+ )
183
+ else:
184
+ print("\nBinary content (hex):")
185
+ print(result["hex_preview"])
186
+ if result["size_bytes"] > max_size:
187
+ print(
188
+ f"\n... (showing first {max_size} bytes of {result['size_bytes']} total) ..."
189
+ )
190
+
191
+ except Exception as e:
192
+ print(f"Error retrieving content: {e}")
193
+ return 1
194
+
195
+ return 0
196
+
197
+
198
+ def handle_store(client, file_path, miner_ids, encrypt=None):
199
+ """Handle the store command"""
200
+ if not os.path.exists(file_path):
201
+ print(f"Error: File {file_path} not found")
202
+ return 1
203
+
204
+ print(f"Uploading {file_path} to IPFS...")
205
+ start_time = time.time()
206
+
207
+ # Use the enhanced upload_file method that returns formatted information
208
+ result = client.upload_file(file_path, encrypt=encrypt)
209
+
210
+ ipfs_elapsed_time = time.time() - start_time
211
+
212
+ print(f"IPFS upload successful in {ipfs_elapsed_time:.2f} seconds!")
213
+ print(f"CID: {result['cid']}")
214
+ print(f"Filename: {result['filename']}")
215
+ print(f"Size: {result['size_bytes']:,} bytes ({result['size_formatted']})")
216
+
217
+ if result.get("encrypted"):
218
+ print("File was encrypted before upload")
219
+
220
+ # Store the file on Substrate
221
+ print("\nStoring the file on Substrate...")
222
+ start_time = time.time()
223
+
224
+ try:
225
+ # Create a file input object for the marketplace
226
+ file_input = {"fileHash": result["cid"], "fileName": result["filename"]}
227
+
228
+ # Store on Substrate
229
+ client.substrate_client.storage_request([file_input], miner_ids)
230
+
231
+ substrate_elapsed_time = time.time() - start_time
232
+ print(
233
+ f"Substrate storage request completed in {substrate_elapsed_time:.2f} seconds!"
234
+ )
235
+
236
+ # Suggestion to verify
237
+ print("\nTo verify the IPFS upload, you can run:")
238
+ print(f" hippius exists {result['cid']}")
239
+ print(f" hippius cat {result['cid']}")
240
+
241
+ except NotImplementedError as e:
242
+ print(f"\nNote: {e}")
243
+ except Exception as e:
244
+ print(f"\nError storing file on Substrate: {e}")
245
+ return 1
246
+
247
+ return 0
248
+
249
+
250
+ def handle_store_dir(client, dir_path, miner_ids, encrypt=None):
251
+ """Handle the store-dir command"""
252
+ if not os.path.isdir(dir_path):
253
+ print(f"Error: Directory {dir_path} not found")
254
+ return 1
255
+
256
+ print(f"Uploading directory {dir_path} to IPFS...")
257
+ start_time = time.time()
258
+
259
+ # We'll manually upload each file first to get individual CIDs
260
+ all_files = []
261
+ for root, _, files in os.walk(dir_path):
262
+ for file in files:
263
+ file_path = os.path.join(root, file)
264
+ rel_path = os.path.relpath(file_path, dir_path)
265
+ all_files.append((file_path, rel_path))
266
+
267
+ print(f"Found {len(all_files)} files to upload")
268
+
269
+ # Upload each file individually to get all CIDs
270
+ individual_cids = []
271
+ for file_path, rel_path in all_files:
272
+ try:
273
+ print(f" Uploading: {rel_path}")
274
+ file_result = client.upload_file(file_path, encrypt=encrypt)
275
+ individual_cids.append(
276
+ {
277
+ "path": rel_path,
278
+ "cid": file_result["cid"],
279
+ "filename": file_result["filename"],
280
+ "size_bytes": file_result["size_bytes"],
281
+ "size_formatted": file_result.get("size_formatted", ""),
282
+ "encrypted": file_result.get("encrypted", False),
283
+ }
284
+ )
285
+ print(
286
+ f" CID: {individual_cids[-1]['cid']} ({individual_cids[-1]['size_formatted']})"
287
+ )
288
+ if file_result.get("encrypted"):
289
+ print(f" Encrypted: Yes")
290
+ except Exception as e:
291
+ print(f" Error uploading {rel_path}: {e}")
292
+
293
+ # Now upload the entire directory
294
+ result = client.upload_directory(dir_path, encrypt=encrypt)
295
+
296
+ ipfs_elapsed_time = time.time() - start_time
297
+
298
+ print(f"\nIPFS directory upload successful in {ipfs_elapsed_time:.2f} seconds!")
299
+ print(f"Directory CID: {result['cid']}")
300
+ print(f"Directory name: {result['dirname']}")
301
+ print(f"Total files: {result.get('file_count', len(individual_cids))}")
302
+ print(f"Total size: {result.get('size_formatted', 'Unknown')}")
303
+
304
+ if result.get("encrypted"):
305
+ print("Files were encrypted before upload")
306
+
307
+ # Print summary of all individual file CIDs
308
+ print(f"\nAll individual file CIDs ({len(individual_cids)}):")
309
+ for item in individual_cids:
310
+ print(f" {item['path']}: {item['cid']} ({item['size_formatted']})")
311
+
312
+ # Suggestion to verify
313
+ print("\nTo verify the IPFS directory upload, you can run:")
314
+ print(f" hippius exists {result['cid']}")
315
+
316
+ # Store all files on Substrate
317
+ print("\nStoring all files on Substrate...")
318
+ start_time = time.time()
319
+
320
+ try:
321
+ # Create file input objects for the marketplace
322
+ file_inputs = []
323
+ for item in individual_cids:
324
+ file_inputs.append({"fileHash": item["cid"], "fileName": item["filename"]})
325
+
326
+ # Store all files in a single batch request
327
+ client.substrate_client.storage_request(file_inputs, miner_ids)
328
+
329
+ substrate_elapsed_time = time.time() - start_time
330
+ print(
331
+ f"Substrate storage request completed in {substrate_elapsed_time:.2f} seconds!"
332
+ )
333
+
334
+ except NotImplementedError as e:
335
+ print(f"\nNote: {e}")
336
+ except Exception as e:
337
+ print(f"\nError storing files on Substrate: {e}")
338
+ return 1
339
+
340
+ return 0
341
+
342
+
343
+ def handle_credits(client, account_address):
344
+ """Handle the credits command"""
345
+ print("Checking free credits for the account...")
346
+ try:
347
+ credits = client.substrate_client.get_free_credits(account_address)
348
+ print(f"\nFree credits: {credits:.6f}")
349
+ raw_value = int(
350
+ credits * 1_000_000_000_000_000_000
351
+ ) # Convert back to raw for display
352
+ print(f"Raw value: {raw_value:,}")
353
+ print(
354
+ f"Account address: {account_address or client.substrate_client._keypair.ss58_address}"
355
+ )
356
+ except Exception as e:
357
+ print(f"Error checking credits: {e}")
358
+ return 1
359
+
360
+ return 0
361
+
362
+
363
+ def handle_files(client, account_address, debug=False, show_all_miners=False):
364
+ """Handle the files command"""
365
+ print("Retrieving file information...")
366
+ try:
367
+ if debug:
368
+ print("DEBUG MODE: Will show details about CID decoding")
369
+
370
+ # Use the enhanced get_user_files method with our preferences
371
+ max_miners = 0 if show_all_miners else 3 # 0 means show all miners
372
+ files = client.substrate_client.get_user_files(
373
+ account_address,
374
+ truncate_miners=True, # Always truncate long miner IDs
375
+ max_miners=max_miners, # Use 0 for all or 3 for limited
376
+ )
377
+
378
+ if files:
379
+ print(
380
+ f"\nFound {len(files)} files for account: {account_address or client.substrate_client._keypair.ss58_address}"
381
+ )
382
+ print("\n" + "-" * 80)
383
+
384
+ for i, file in enumerate(files, 1):
385
+ print(f"File {i}:")
386
+
387
+ # Format the CID using the SDK method
388
+ file_hash = file.get("file_hash", "Unknown")
389
+ formatted_cid = client.format_cid(file_hash)
390
+ print(f" File Hash (CID): {formatted_cid}")
391
+
392
+ # Display file name
393
+ print(f" File Name: {file.get('file_name', 'Unnamed')}")
394
+
395
+ # Display file size with SDK formatting method if needed
396
+ file_size = file.get("file_size", 0)
397
+ size_formatted = file.get("size_formatted")
398
+ if not size_formatted and file_size > 0:
399
+ size_formatted = client.format_size(file_size)
400
+ print(f" File Size: {file_size:,} bytes ({size_formatted})")
401
+
402
+ # Display miners
403
+ miner_count = file.get("miner_count", 0)
404
+ miners = file.get("miner_ids", [])
405
+
406
+ if miner_count > 0:
407
+ print(f" Pinned by {miner_count} miners:")
408
+
409
+ # Show message about truncated list if applicable
410
+ if miner_count > len(miners) and not show_all_miners:
411
+ print(
412
+ f" (Showing {len(miners)} of {miner_count} miners - use --all-miners to see all)"
413
+ )
414
+ elif miner_count > 3 and show_all_miners:
415
+ print(f" (Showing all {miner_count} miners)")
416
+
417
+ # Display the miners using their formatted IDs
418
+ for miner in miners:
419
+ if isinstance(miner, dict) and "formatted" in miner:
420
+ print(f" - {miner['formatted']}")
421
+ else:
422
+ print(f" - {miner}")
423
+ else:
424
+ print(" Not pinned by any miners")
425
+
426
+ print("-" * 80)
427
+ else:
428
+ print(
429
+ f"No files found for account: {account_address or client.substrate_client._keypair.ss58_address}"
430
+ )
431
+ except Exception as e:
432
+ print(f"Error retrieving file information: {e}")
433
+ return 1
434
+
435
+ return 0
436
+
437
+
438
+ def main():
439
+ """Main entry point for the Hippius CLI."""
440
+ parser = argparse.ArgumentParser(
441
+ description="Hippius SDK Command Line Interface",
442
+ formatter_class=argparse.RawDescriptionHelpFormatter,
443
+ epilog="""
444
+ Examples:
445
+ hippius download QmCID123 downloaded_file.txt
446
+ hippius exists QmCID123
447
+ hippius cat QmCID123
448
+ hippius store test_file.txt
449
+ hippius store-dir ./test_directory
450
+ hippius credits
451
+ hippius credits 5H1QBRF7T7dgKwzVGCgS4wioudvMRf9K4NEDzfuKLnuyBNzH
452
+ hippius files
453
+ hippius files 5H1QBRF7T7dgKwzVGCgS4wioudvMRf9K4NEDzfuKLnuyBNzH
454
+ hippius files --all-miners
455
+ hippius keygen
456
+ hippius keygen --copy
457
+ """,
458
+ )
459
+
460
+ # Optional arguments for all commands
461
+ parser.add_argument(
462
+ "--gateway",
463
+ default=os.getenv("IPFS_GATEWAY", "https://ipfs.io"),
464
+ help="IPFS gateway URL for downloads (default: from env or https://ipfs.io)",
465
+ )
466
+ parser.add_argument(
467
+ "--api-url",
468
+ default=os.getenv("IPFS_API_URL", "https://relay-fr.hippius.network"),
469
+ help="IPFS API URL for uploads (default: from env or https://relay-fr.hippius.network)",
470
+ )
471
+ parser.add_argument(
472
+ "--local-ipfs",
473
+ action="store_true",
474
+ help="Use local IPFS node (http://localhost:5001) instead of remote API",
475
+ )
476
+ parser.add_argument(
477
+ "--substrate-url",
478
+ default=os.getenv("SUBSTRATE_URL", "wss://rpc.hippius.network"),
479
+ help="Substrate node WebSocket URL (default: from env or wss://rpc.hippius.network)",
480
+ )
481
+ parser.add_argument(
482
+ "--miner-ids",
483
+ help="Comma-separated list of miner IDs for storage (default: from env SUBSTRATE_DEFAULT_MINERS)",
484
+ )
485
+ parser.add_argument(
486
+ "--verbose", "-v", action="store_true", help="Enable verbose debug output"
487
+ )
488
+ parser.add_argument(
489
+ "--encrypt",
490
+ action="store_true",
491
+ help="Encrypt files when uploading (overrides default)",
492
+ )
493
+ parser.add_argument(
494
+ "--no-encrypt",
495
+ action="store_true",
496
+ help="Do not encrypt files when uploading (overrides default)",
497
+ )
498
+ parser.add_argument(
499
+ "--decrypt",
500
+ action="store_true",
501
+ help="Decrypt files when downloading (overrides default)",
502
+ )
503
+ parser.add_argument(
504
+ "--no-decrypt",
505
+ action="store_true",
506
+ help="Do not decrypt files when downloading (overrides default)",
507
+ )
508
+ parser.add_argument(
509
+ "--encryption-key",
510
+ help="Base64-encoded encryption key (overrides HIPPIUS_ENCRYPTION_KEY in .env)",
511
+ )
512
+
513
+ # Subcommands
514
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
515
+
516
+ # Download command
517
+ download_parser = subparsers.add_parser(
518
+ "download", help="Download a file from IPFS"
519
+ )
520
+ download_parser.add_argument("cid", help="CID of file to download")
521
+ download_parser.add_argument("output_path", help="Path to save downloaded file")
522
+
523
+ # Exists command
524
+ exists_parser = subparsers.add_parser(
525
+ "exists", help="Check if a CID exists on IPFS"
526
+ )
527
+ exists_parser.add_argument("cid", help="CID to check")
528
+
529
+ # Cat command
530
+ cat_parser = subparsers.add_parser(
531
+ "cat", help="Display content of a file from IPFS"
532
+ )
533
+ cat_parser.add_argument("cid", help="CID of file to display")
534
+ cat_parser.add_argument(
535
+ "--max-size",
536
+ type=int,
537
+ default=1024,
538
+ help="Maximum number of bytes to display (default: 1024)",
539
+ )
540
+
541
+ # Store command (upload to IPFS then store on Substrate)
542
+ store_parser = subparsers.add_parser(
543
+ "store", help="Upload a file to IPFS and store it on Substrate"
544
+ )
545
+ store_parser.add_argument("file_path", help="Path to file to upload")
546
+
547
+ # Store directory command
548
+ store_dir_parser = subparsers.add_parser(
549
+ "store-dir", help="Upload a directory to IPFS and store all files on Substrate"
550
+ )
551
+ store_dir_parser.add_argument("dir_path", help="Path to directory to upload")
552
+
553
+ # Credits command
554
+ credits_parser = subparsers.add_parser(
555
+ "credits", help="Check free credits for an account in the marketplace"
556
+ )
557
+ credits_parser.add_argument(
558
+ "account_address",
559
+ nargs="?",
560
+ default=None,
561
+ help="Substrate account address (uses keypair address if not specified)",
562
+ )
563
+
564
+ # Files command
565
+ files_parser = subparsers.add_parser(
566
+ "files", help="View detailed information about files stored by a user"
567
+ )
568
+ files_parser.add_argument(
569
+ "account_address",
570
+ nargs="?",
571
+ default=None,
572
+ help="Substrate account address (uses keypair address if not specified)",
573
+ )
574
+ files_parser.add_argument(
575
+ "--debug", action="store_true", help="Show debug information about CID decoding"
576
+ )
577
+ files_parser.add_argument(
578
+ "--all-miners",
579
+ action="store_true",
580
+ help="Show all miners for each file instead of only the first 3",
581
+ )
582
+
583
+ # Key generation command
584
+ keygen_parser = subparsers.add_parser(
585
+ "keygen", help="Generate an encryption key for secure file storage"
586
+ )
587
+ keygen_parser.add_argument(
588
+ "--copy", action="store_true", help="Copy the generated key to the clipboard"
589
+ )
590
+
591
+ args = parser.parse_args()
592
+
593
+ if not args.command:
594
+ parser.print_help()
595
+ return 1
596
+
597
+ # Special case for keygen which doesn't need client initialization
598
+ if args.command == "keygen":
599
+ # Handle key generation separately
600
+ if args.copy:
601
+ return key_generation_cli()
602
+ else:
603
+ # Create a new argparse namespace with just the copy flag for compatibility
604
+ keygen_args = argparse.Namespace(copy=False)
605
+ return key_generation_cli()
606
+
607
+ try:
608
+ # Parse miner IDs if provided
609
+ miner_ids = None
610
+ if args.miner_ids:
611
+ miner_ids = [miner.strip() for miner in args.miner_ids.split(",")]
612
+ elif os.getenv("SUBSTRATE_DEFAULT_MINERS"):
613
+ miner_ids = [
614
+ miner.strip()
615
+ for miner in os.getenv("SUBSTRATE_DEFAULT_MINERS").split(",")
616
+ ]
617
+
618
+ # Create client
619
+ client, encrypt, decrypt = create_client(args)
620
+
621
+ # Handle commands
622
+ if args.command == "download":
623
+ return handle_download(client, args.cid, args.output_path, decrypt=decrypt)
624
+
625
+ elif args.command == "exists":
626
+ return handle_exists(client, args.cid)
627
+
628
+ elif args.command == "cat":
629
+ return handle_cat(client, args.cid, args.max_size, decrypt=decrypt)
630
+
631
+ elif args.command == "store":
632
+ return handle_store(client, args.file_path, miner_ids, encrypt=encrypt)
633
+
634
+ elif args.command == "store-dir":
635
+ return handle_store_dir(client, args.dir_path, miner_ids, encrypt=encrypt)
636
+
637
+ elif args.command == "credits":
638
+ return handle_credits(client, args.account_address)
639
+
640
+ elif args.command == "files":
641
+ return handle_files(
642
+ client,
643
+ args.account_address,
644
+ debug=args.debug if hasattr(args, "debug") else False,
645
+ show_all_miners=args.all_miners
646
+ if hasattr(args, "all_miners")
647
+ else False,
648
+ )
649
+
650
+ except Exception as e:
651
+ print(f"Error: {e}")
652
+ return 1
653
+
654
+ return 0
655
+
656
+
657
+ if __name__ == "__main__":
658
+ sys.exit(main())