rnx.py
1 #!/usr/bin/env python3 2 3 # Reticulum License 4 # 5 # Copyright (c) 2016-2025 Mark Qvist 6 # 7 # Permission is hereby granted, free of charge, to any person obtaining a copy 8 # of this software and associated documentation files (the "Software"), to deal 9 # in the Software without restriction, including without limitation the rights 10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 # copies of the Software, and to permit persons to whom the Software is 12 # furnished to do so, subject to the following conditions: 13 # 14 # - The Software shall not be used in any kind of system which includes amongst 15 # its functions the ability to purposefully do harm to human beings. 16 # 17 # - The Software shall not be used, directly or indirectly, in the creation of 18 # an artificial intelligence, machine learning or language model training 19 # dataset, including but not limited to any use that contributes to the 20 # training or development of such a model or algorithm. 21 # 22 # - The above copyright notice and this permission notice shall be included in 23 # all copies or substantial portions of the Software. 24 # 25 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 # SOFTWARE. 32 33 import RNS 34 import subprocess 35 import argparse 36 import shlex 37 import time 38 import sys 39 import os 40 #import tty 41 42 from RNS._version import __version__ 43 44 APP_NAME = "rnx" 45 identity = None 46 reticulum = None 47 allow_all = False 48 allowed_identity_hashes = [] 49 50 def prepare_identity(identity_path): 51 global identity 52 if identity_path == None: 53 identity_path = RNS.Reticulum.identitypath+"/"+APP_NAME 54 55 if os.path.isfile(identity_path): 56 identity = RNS.Identity.from_file(identity_path) 57 58 if identity == None: 59 RNS.log("No valid saved identity found, creating new...", RNS.LOG_INFO) 60 identity = RNS.Identity() 61 identity.to_file(identity_path) 62 63 def listen(configdir, identitypath = None, verbosity = 0, quietness = 0, allowed = [], print_identity = False, disable_auth = None, disable_announce=False): 64 global identity, allow_all, allowed_identity_hashes, reticulum 65 66 targetloglevel = 3+verbosity-quietness 67 reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel) 68 69 prepare_identity(identitypath) 70 destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "execute") 71 72 if print_identity: 73 print("Identity : "+str(identity)) 74 print("Listening on : "+RNS.prettyhexrep(destination.hash)) 75 exit(0) 76 77 if disable_auth: 78 allow_all = True 79 else: 80 if allowed != None: 81 for a in allowed: 82 try: 83 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 84 if len(a) != dest_len: 85 raise ValueError("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 86 try: 87 destination_hash = bytes.fromhex(a) 88 allowed_identity_hashes.append(destination_hash) 89 except Exception as e: 90 raise ValueError("Invalid destination entered. Check your input.") 91 except Exception as e: 92 print(str(e)) 93 exit(1) 94 try: 95 allowed_file_name = "allowed_identities" 96 allowed_file = None 97 if os.path.isfile(os.path.expanduser("/etc/rnx/"+allowed_file_name)): 98 allowed_file = os.path.expanduser("/etc/rnx/"+allowed_file_name) 99 elif os.path.isfile(os.path.expanduser("~/.config/rnx/"+allowed_file_name)): 100 allowed_file = os.path.expanduser("~/.config/rnx/"+allowed_file_name) 101 elif os.path.isfile(os.path.expanduser("~/.rnx/"+allowed_file_name)): 102 allowed_file = os.path.expanduser("~/.rnx/"+allowed_file_name) 103 if allowed_file != None: 104 with open(allowed_file, "r") as af_handle: 105 allowed_by_file = af_handle.read().replace("\r", "").split("\n") 106 for allowed_ID in allowed_by_file: 107 if len(allowed_ID) == (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2: 108 allowed_identity_hashes.append(bytes.fromhex(allowed_ID)) 109 except Exception as e: 110 print(str(e)) 111 exit(1) 112 113 if len(allowed_identity_hashes) < 1 and not disable_auth: 114 print("Warning: No allowed identities configured, rncx will not accept any commands!") 115 116 destination.set_link_established_callback(command_link_established) 117 118 if not allow_all: 119 destination.register_request_handler( 120 path = "command", 121 response_generator = execute_received_command, 122 allow = RNS.Destination.ALLOW_LIST, 123 allowed_list = allowed_identity_hashes 124 ) 125 else: 126 destination.register_request_handler( 127 path = "command", 128 response_generator = execute_received_command, 129 allow = RNS.Destination.ALLOW_ALL, 130 ) 131 132 RNS.log("rnx listening for commands on "+RNS.prettyhexrep(destination.hash)) 133 134 if not disable_announce: 135 destination.announce() 136 137 while True: 138 time.sleep(1) 139 140 def command_link_established(link): 141 link.set_remote_identified_callback(initiator_identified) 142 link.set_link_closed_callback(command_link_closed) 143 RNS.log("Command link "+str(link)+" established") 144 145 def command_link_closed(link): 146 RNS.log("Command link "+str(link)+" closed") 147 148 def initiator_identified(link, identity): 149 global allow_all 150 RNS.log("Initiator of link "+str(link)+" identified as "+RNS.prettyhexrep(identity.hash)) 151 if not allow_all and not identity.hash in allowed_identity_hashes: 152 RNS.log("Identity "+RNS.prettyhexrep(identity.hash)+" not allowed, tearing down link") 153 link.teardown() 154 155 def execute_received_command(path, data, request_id, remote_identity, requested_at): 156 command = data[0].decode("utf-8") # Command to execute 157 timeout = data[1] # Timeout in seconds 158 o_limit = data[2] # Size limit for stdout 159 e_limit = data[3] # Size limit for stderr 160 stdin = data[4] # Data passed to stdin 161 162 if remote_identity != None: 163 RNS.log("Executing command ["+command+"] for "+RNS.prettyhexrep(remote_identity.hash)) 164 else: 165 RNS.log("Executing command ["+command+"] for unknown requestor") 166 167 result = [ 168 False, # 0: Command was executed 169 None, # 1: Return value 170 None, # 2: Stdout 171 None, # 3: Stderr 172 None, # 4: Total stdout length 173 None, # 5: Total stderr length 174 time.time(), # 6: Started 175 None, # 7: Concluded 176 ] 177 178 try: 179 process = subprocess.Popen(shlex.split(command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 180 result[0] = True 181 182 except Exception as e: 183 result[0] = False 184 return result 185 186 stdout = b"" 187 stderr = b"" 188 timed_out = False 189 190 if stdin != None: 191 process.stdin.write(stdin) 192 193 while True: 194 try: 195 stdout, stderr = process.communicate(timeout=1) 196 if process.poll() != None: 197 break 198 199 if len(stdout) > 0: 200 print(str(stdout)) 201 sys.stdout.flush() 202 203 except subprocess.TimeoutExpired: 204 pass 205 206 if timeout != None and time.time() > result[6]+timeout: 207 RNS.log("Command ["+command+"] timed out and is being killed...") 208 process.terminate() 209 process.wait() 210 if process.poll() != None: 211 stdout, stderr = process.communicate() 212 else: 213 stdout = None 214 stderr = None 215 216 break 217 218 if timeout != None and time.time() < result[6]+timeout: 219 result[7] = time.time() 220 221 # Deliver result 222 result[1] = process.returncode 223 224 if o_limit != None and len(stdout) > o_limit: 225 if o_limit == 0: 226 result[2] = b"" 227 else: 228 result[2] = stdout[0:o_limit] 229 else: 230 result[2] = stdout 231 232 if e_limit != None and len(stderr) > e_limit: 233 if e_limit == 0: 234 result[3] = b"" 235 else: 236 result[3] = stderr[0:e_limit] 237 else: 238 result[3] = stderr 239 240 result[4] = len(stdout) 241 result[5] = len(stderr) 242 243 if timed_out: 244 RNS.log("Command timed out") 245 return result 246 247 if remote_identity != None: 248 RNS.log("Delivering result of command ["+str(command)+"] to "+RNS.prettyhexrep(remote_identity.hash)) 249 else: 250 RNS.log("Delivering result of command ["+str(command)+"] to unknown requestor") 251 252 return result 253 254 def spin(until=None, msg=None, timeout=None): 255 i = 0 256 syms = "⢄⢂⢁⡁⡈⡐⡠" 257 if timeout != None: 258 timeout = time.time()+timeout 259 260 print(msg+" ", end=" ") 261 while (timeout == None or time.time()<timeout) and not until(): 262 time.sleep(0.1) 263 print(("\b\b"+syms[i]+" "), end="") 264 sys.stdout.flush() 265 i = (i+1)%len(syms) 266 267 print("\r"+" "*len(msg)+" \r", end="") 268 269 if timeout != None and time.time() > timeout: 270 return False 271 else: 272 return True 273 274 current_progress = 0.0 275 stats = [] 276 speed = 0.0 277 def spin_stat(until=None, timeout=None): 278 global current_progress, response_transfer_size, speed 279 i = 0 280 syms = "⢄⢂⢁⡁⡈⡐⡠" 281 if timeout != None: 282 timeout = time.time()+timeout 283 284 while (timeout == None or time.time()<timeout) and not until(): 285 time.sleep(0.1) 286 prg = current_progress 287 percent = round(prg * 100.0, 1) 288 stat_str = str(percent)+"% - " + size_str(int(prg*response_transfer_size)) + " of " + size_str(response_transfer_size) + " - " +size_str(speed, "b")+"ps" 289 print("\r \rReceiving result "+syms[i]+" "+stat_str, end=" ") 290 291 sys.stdout.flush() 292 i = (i+1)%len(syms) 293 294 print("\r \r", end="") 295 296 if timeout != None and time.time() > timeout: 297 return False 298 else: 299 return True 300 301 def remote_execution_done(request_receipt): 302 pass 303 304 def remote_execution_progress(request_receipt): 305 stats_max = 32 306 global current_progress, response_transfer_size, speed 307 current_progress = request_receipt.progress 308 response_transfer_size = request_receipt.response_transfer_size 309 now = time.time() 310 got = current_progress*response_transfer_size 311 entry = [now, got] 312 stats.append(entry) 313 while len(stats) > stats_max: 314 stats.pop(0) 315 316 span = now - stats[0][0] 317 if span == 0: 318 speed = 0 319 else: 320 diff = got - stats[0][1] 321 speed = diff/span 322 323 link = None 324 listener_destination = None 325 remote_exec_grace = 2.0 326 def execute(configdir, identitypath = None, verbosity = 0, quietness = 0, detailed = False, mirror = False, noid = False, destination = None, command = None, stdin = None, stdoutl = None, stderrl = None, timeout = RNS.Transport.PATH_REQUEST_TIMEOUT, result_timeout = None, interactive = False): 327 global identity, reticulum, link, listener_destination, remote_exec_grace 328 329 try: 330 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2 331 if len(destination) != dest_len: 332 raise ValueError("Allowed destination length is invalid, must be {hex} hexadecimal characters ({byte} bytes).".format(hex=dest_len, byte=dest_len//2)) 333 try: 334 destination_hash = bytes.fromhex(destination) 335 except Exception as e: 336 raise ValueError("Invalid destination entered. Check your input.") 337 except Exception as e: 338 print(str(e)) 339 exit(241) 340 341 if reticulum == None: 342 targetloglevel = 3+verbosity-quietness 343 reticulum = RNS.Reticulum(configdir=configdir, loglevel=targetloglevel) 344 345 if identity == None: 346 prepare_identity(identitypath) 347 348 if not RNS.Transport.has_path(destination_hash): 349 RNS.Transport.request_path(destination_hash) 350 if not spin(until=lambda: RNS.Transport.has_path(destination_hash), msg="Path to "+RNS.prettyhexrep(destination_hash)+" requested", timeout=timeout): 351 print("Path not found") 352 exit(242) 353 354 if listener_destination == None: 355 listener_identity = RNS.Identity.recall(destination_hash) 356 listener_destination = RNS.Destination( 357 listener_identity, 358 RNS.Destination.OUT, 359 RNS.Destination.SINGLE, 360 APP_NAME, 361 "execute" 362 ) 363 364 if link == None or link.status == RNS.Link.CLOSED or link.status == RNS.Link.PENDING: 365 link = RNS.Link(listener_destination) 366 link.did_identify = False 367 368 if not spin(until=lambda: link.status == RNS.Link.ACTIVE, msg="Establishing link with "+RNS.prettyhexrep(destination_hash), timeout=timeout): 369 print("Could not establish link with "+RNS.prettyhexrep(destination_hash)) 370 exit(243) 371 372 if not noid and not link.did_identify: 373 link.identify(identity) 374 link.did_identify = True 375 376 if stdin != None: 377 stdin = stdin.encode("utf-8") 378 379 request_data = [ 380 command.encode("utf-8"), # Command to execute 381 timeout, # Timeout in seconds 382 stdoutl, # Size limit for stdout 383 stderrl, # Size limit for stderr 384 stdin, # Data passed to stdin 385 ] 386 387 # TODO: Tune 388 rexec_timeout = timeout+link.rtt*4+remote_exec_grace 389 390 request_receipt = link.request( 391 path="command", 392 data=request_data, 393 response_callback=remote_execution_done, 394 failed_callback=remote_execution_done, 395 progress_callback=remote_execution_progress, 396 timeout=rexec_timeout 397 ) 398 399 spin( 400 until=lambda:link.status == RNS.Link.CLOSED or (request_receipt.status != RNS.RequestReceipt.FAILED and request_receipt.status != RNS.RequestReceipt.SENT), 401 msg="Sending execution request", 402 timeout=rexec_timeout+0.5 403 ) 404 405 if link.status == RNS.Link.CLOSED: 406 print("Could not request remote execution, link was closed") 407 exit(244) 408 409 if request_receipt.status == RNS.RequestReceipt.FAILED: 410 print("Could not request remote execution") 411 if interactive: 412 return 413 else: 414 exit(244) 415 416 spin( 417 until=lambda:request_receipt.status != RNS.RequestReceipt.DELIVERED, 418 msg="Command delivered, awaiting result", 419 timeout=timeout 420 ) 421 422 if request_receipt.status == RNS.RequestReceipt.FAILED: 423 print("No result was received") 424 if interactive: 425 return 426 else: 427 exit(245) 428 429 spin_stat( 430 until=lambda:request_receipt.status != RNS.RequestReceipt.RECEIVING, 431 timeout=result_timeout 432 ) 433 434 if request_receipt.status == RNS.RequestReceipt.FAILED: 435 print("Receiving result failed") 436 if interactive: 437 return 438 else: 439 exit(246) 440 441 if request_receipt.response != None: 442 try: 443 executed = request_receipt.response[0] 444 retval = request_receipt.response[1] 445 stdout = request_receipt.response[2] 446 stderr = request_receipt.response[3] 447 outlen = request_receipt.response[4] 448 errlen = request_receipt.response[5] 449 started = request_receipt.response[6] 450 concluded = request_receipt.response[7] 451 452 except Exception as e: 453 print("Received invalid result") 454 if interactive: 455 return 456 else: 457 exit(247) 458 459 if executed: 460 if detailed: 461 if stdout != None and len(stdout) > 0: 462 print(stdout.decode("utf-8"), end="") 463 if stderr != None and len(stderr) > 0: 464 print(stderr.decode("utf-8"), file=sys.stderr, end="") 465 466 sys.stdout.flush() 467 sys.stderr.flush() 468 469 print("\n--- End of remote output, rnx done ---") 470 if started != None and concluded != None: 471 cmd_duration = round(concluded - started, 3) 472 print("Remote command execution took "+str(cmd_duration)+" seconds") 473 474 total_size = request_receipt.response_size 475 if request_receipt.request_size != None: 476 total_size += request_receipt.request_size 477 478 transfer_duration = round(request_receipt.response_concluded_at - request_receipt.sent_at - cmd_duration, 3) 479 if transfer_duration == 1: 480 tdstr = " in 1 second" 481 elif transfer_duration < 10: 482 tdstr = " in "+str(transfer_duration)+" seconds" 483 else: 484 tdstr = " in "+pretty_time(transfer_duration) 485 486 spdstr = ", effective rate "+size_str(total_size/transfer_duration, "b")+"ps" 487 488 print("Transferred "+size_str(total_size)+tdstr+spdstr) 489 490 if outlen != None and stdout != None: 491 if len(stdout) < outlen: 492 tstr = ", "+str(len(stdout))+" bytes displayed" 493 else: 494 tstr = "" 495 print("Remote wrote "+str(outlen)+" bytes to stdout"+tstr) 496 497 if errlen != None and stderr != None: 498 if len(stderr) < errlen: 499 tstr = ", "+str(len(stderr))+" bytes displayed" 500 else: 501 tstr = "" 502 print("Remote wrote "+str(errlen)+" bytes to stderr"+tstr) 503 504 else: 505 if stdout != None and len(stdout) > 0: 506 print(stdout.decode("utf-8"), end="") 507 if stderr != None and len(stderr) > 0: 508 print(stderr.decode("utf-8"), file=sys.stderr, end="") 509 510 511 if (stdoutl != 0 and len(stdout) < outlen) or (stderrl != 0 and len(stderr) < errlen): 512 sys.stdout.flush() 513 sys.stderr.flush() 514 print("\nOutput truncated before being returned:") 515 if len(stdout) != 0 and len(stdout) < outlen: 516 print(" stdout truncated to "+str(len(stdout))+" bytes") 517 if len(stderr) != 0 and len(stderr) < errlen: 518 print(" stderr truncated to "+str(len(stderr))+" bytes") 519 else: 520 print("Remote could not execute command") 521 if interactive: 522 return 523 else: 524 exit(248) 525 else: 526 print("No response") 527 if interactive: 528 return 529 else: 530 exit(249) 531 532 try: 533 if not interactive: 534 link.teardown() 535 536 except Exception as e: 537 pass 538 539 if not interactive and mirror: 540 if request_receipt.response[1] != None: 541 exit(request_receipt.response[1]) 542 else: 543 exit(240) 544 else: 545 if interactive: 546 if mirror: 547 return request_receipt.response[1] 548 else: 549 return None 550 else: 551 exit(0) 552 553 def main(): 554 try: 555 parser = argparse.ArgumentParser(description="Reticulum Remote Execution Utility") 556 parser.add_argument("destination", nargs="?", default=None, help="hexadecimal hash of the listener", type=str) 557 parser.add_argument("command", nargs="?", default=None, help="command to be execute", type=str) 558 parser.add_argument("--config", metavar="path", action="store", default=None, help="path to alternative Reticulum config directory", type=str) 559 parser.add_argument('-v', '--verbose', action='count', default=0, help="increase verbosity") 560 parser.add_argument('-q', '--quiet', action='count', default=0, help="decrease verbosity") 561 parser.add_argument('-p', '--print-identity', action='store_true', default=False, help="print identity and destination info and exit") 562 parser.add_argument("-l", '--listen', action='store_true', default=False, help="listen for incoming commands") 563 parser.add_argument('-i', metavar="identity", action='store', dest="identity", default=None, help="path to identity to use", type=str) 564 parser.add_argument("-x", '--interactive', action='store_true', default=False, help="enter interactive mode") 565 parser.add_argument("-b", '--no-announce', action='store_true', default=False, help="don't announce at program start") 566 parser.add_argument('-a', metavar="allowed_hash", dest="allowed", action='append', help="accept from this identity", type=str) 567 parser.add_argument('-n', '--noauth', action='store_true', default=False, help="accept commands from anyone") 568 parser.add_argument('-N', '--noid', action='store_true', default=False, help="don't identify to listener") 569 parser.add_argument("-d", '--detailed', action='store_true', default=False, help="show detailed result output") 570 parser.add_argument("-m", action='store_true', dest="mirror", default=False, help="mirror exit code of remote command") 571 parser.add_argument("-w", action="store", metavar="seconds", type=float, help="connect and request timeout before giving up", default=RNS.Transport.PATH_REQUEST_TIMEOUT) 572 parser.add_argument("-W", action="store", metavar="seconds", type=float, help="max result download time", default=None) 573 parser.add_argument("--stdin", action='store', default=None, help="pass input to stdin", type=str) 574 parser.add_argument("--stdout", action='store', default=None, help="max size in bytes of returned stdout", type=int) 575 parser.add_argument("--stderr", action='store', default=None, help="max size in bytes of returned stderr", type=int) 576 parser.add_argument("--version", action="version", version="rnx {version}".format(version=__version__)) 577 578 args = parser.parse_args() 579 580 if args.listen or args.print_identity: 581 listen( 582 configdir = args.config, 583 identitypath = args.identity, 584 verbosity=args.verbose, 585 quietness=args.quiet, 586 allowed = args.allowed, 587 print_identity=args.print_identity, 588 disable_auth=args.noauth, 589 disable_announce=args.no_announce, 590 ) 591 592 elif args.destination != None and args.command != None: 593 execute( 594 configdir = args.config, 595 identitypath = args.identity, 596 verbosity = args.verbose, 597 quietness = args.quiet, 598 detailed = args.detailed, 599 mirror = args.mirror, 600 noid = args.noid, 601 destination = args.destination, 602 command = args.command, 603 stdin = args.stdin, 604 stdoutl = args.stdout, 605 stderrl = args.stderr, 606 timeout = args.w, 607 result_timeout = args.W, 608 interactive = args.interactive, 609 ) 610 611 if args.destination != None and args.interactive: 612 # command_history_max = 5000 613 # command_history = [] 614 # command_current = "" 615 # history_idx = 0 616 # tty.setcbreak(sys.stdin.fileno()) 617 618 code = None 619 while True: 620 try: 621 cstr = str(code) if code and code != 0 else "" 622 prompt = cstr+"> " 623 print(prompt,end="") 624 625 # cmdbuf = b"" 626 # while True: 627 # ch = sys.stdin.read(1) 628 # cmdbuf += ch.encode("utf-8") 629 # print("\r"+prompt+cmdbuf.decode("utf-8"), end="") 630 631 command = input() 632 if command.lower() == "exit" or command.lower() == "quit": 633 exit(0) 634 635 except KeyboardInterrupt: 636 exit(0) 637 except EOFError: 638 exit(0) 639 640 if command.lower() == "clear": 641 print('\033c', end='') 642 643 # command_history.append(command) 644 # while len(command_history) > command_history_max: 645 # command_history.pop(0) 646 647 else: 648 code = execute( 649 configdir = args.config, 650 identitypath = args.identity, 651 verbosity = args.verbose, 652 quietness = args.quiet, 653 detailed = args.detailed, 654 mirror = args.mirror, 655 noid = args.noid, 656 destination = args.destination, 657 command = command, 658 stdin = None, 659 stdoutl = args.stdout, 660 stderrl = args.stderr, 661 timeout = args.w, 662 result_timeout = args.W, 663 interactive = True, 664 ) 665 666 else: 667 print("") 668 parser.print_help() 669 print("") 670 671 except KeyboardInterrupt: 672 # tty.setnocbreak(sys.stdin.fileno()) 673 print("") 674 if link != None: 675 link.teardown() 676 exit() 677 678 def size_str(num, suffix='B'): 679 units = ['','K','M','G','T','P','E','Z'] 680 last_unit = 'Y' 681 682 if suffix == 'b': 683 num *= 8 684 units = ['','K','M','G','T','P','E','Z'] 685 last_unit = 'Y' 686 687 for unit in units: 688 if abs(num) < 1000.0: 689 if unit == "": 690 return "%.0f %s%s" % (num, unit, suffix) 691 else: 692 return "%.2f %s%s" % (num, unit, suffix) 693 num /= 1000.0 694 695 return "%.2f%s%s" % (num, last_unit, suffix) 696 697 def pretty_time(time, verbose=False): 698 days = int(time // (24 * 3600)) 699 time = time % (24 * 3600) 700 hours = int(time // 3600) 701 time %= 3600 702 minutes = int(time // 60) 703 time %= 60 704 seconds = round(time, 2) 705 706 ss = "" if seconds == 1 else "s" 707 sm = "" if minutes == 1 else "s" 708 sh = "" if hours == 1 else "s" 709 sd = "" if days == 1 else "s" 710 711 components = [] 712 if days > 0: 713 components.append(str(days)+" day"+sd if verbose else str(days)+"d") 714 715 if hours > 0: 716 components.append(str(hours)+" hour"+sh if verbose else str(hours)+"h") 717 718 if minutes > 0: 719 components.append(str(minutes)+" minute"+sm if verbose else str(minutes)+"m") 720 721 if seconds > 0: 722 components.append(str(seconds)+" second"+ss if verbose else str(seconds)+"s") 723 724 i = 0 725 tstr = "" 726 for c in components: 727 i += 1 728 if i == 1: 729 pass 730 elif i < len(components): 731 tstr += ", " 732 elif i == len(components): 733 tstr += " and " 734 735 tstr += c 736 737 return tstr 738 739 if __name__ == "__main__": 740 main()