2021-09-24 10:42:24 +00:00
#!/usr/bin/env python3
2022-04-01 15:18:18 +00:00
# MIT License
#
# Copyright (c) 2016-2022 Mark Qvist / unsigned.io
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
2021-09-24 10:42:24 +00:00
import RNS
2024-08-29 11:19:39 +00:00
import os
2021-09-24 10:42:24 +00:00
import sys
import time
import argparse
from RNS . _version import __version__
2024-08-29 11:19:39 +00:00
remote_link = None
def connect_remote ( destination_hash , auth_identity , timeout , no_output = False ) :
global remote_link , reticulum
if not RNS . Transport . has_path ( destination_hash ) :
if not no_output :
2024-10-07 08:44:18 +00:00
print ( f " Path to { RNS . prettyhexrep ( destination_hash ) } requested " , end = " " )
2024-08-29 11:19:39 +00:00
sys . stdout . flush ( )
RNS . Transport . request_path ( destination_hash )
pr_time = time . time ( )
while not RNS . Transport . has_path ( destination_hash ) :
time . sleep ( 0.1 )
if time . time ( ) - pr_time > timeout :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Path request timed out " )
exit ( 12 )
remote_identity = RNS . Identity . recall ( destination_hash )
def remote_link_closed ( link ) :
if link . teardown_reason == RNS . Link . TIMEOUT :
if not no_output :
print ( " \r \r " , end = " " )
print ( " The link timed out, exiting now " )
elif link . teardown_reason == RNS . Link . DESTINATION_CLOSED :
if not no_output :
print ( " \r \r " , end = " " )
print ( " The link was closed by the server, exiting now " )
else :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Link closed unexpectedly, exiting now " )
exit ( 10 )
def remote_link_established ( link ) :
global remote_link
link . identify ( auth_identity )
remote_link = link
if not no_output :
print ( " \r \r " , end = " " )
print ( " Establishing link with remote transport instance... " , end = " " )
sys . stdout . flush ( )
remote_destination = RNS . Destination ( remote_identity , RNS . Destination . OUT , RNS . Destination . SINGLE , " rnstransport " , " remote " , " management " )
link = RNS . Link ( remote_destination )
link . set_link_established_callback ( remote_link_established )
link . set_link_closed_callback ( remote_link_closed )
2024-08-29 12:51:38 +00:00
def program_setup ( configdir , table , rates , drop , destination_hexhash , verbosity , timeout , drop_queues ,
drop_via , max_hops , remote = None , management_identity = None , remote_timeout = RNS . Transport . PATH_REQUEST_TIMEOUT ,
no_output = False , json = False ) :
2024-08-29 11:19:39 +00:00
global remote_link , reticulum
reticulum = RNS . Reticulum ( configdir = configdir , loglevel = 3 + verbosity )
if remote :
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( remote ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2024-08-29 11:19:39 +00:00
try :
identity_hash = bytes . fromhex ( remote )
remote_hash = RNS . Destination . hash_from_name_and_identity ( " rnstransport.remote.management " , identity_hash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
identity = RNS . Identity . from_file ( os . path . expanduser ( management_identity ) )
if identity == None :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Could not load management identity from { management_identity } " )
2024-08-29 11:19:39 +00:00
try :
connect_remote ( remote_hash , identity , remote_timeout , no_output )
except Exception as e :
raise e
except Exception as e :
print ( str ( e ) )
exit ( 20 )
while remote_link == None :
time . sleep ( 0.1 )
2021-09-24 10:42:24 +00:00
2022-04-20 08:40:51 +00:00
if table :
2022-05-14 20:14:38 +00:00
destination_hash = None
if destination_hexhash != None :
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( destination_hexhash ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2022-05-14 20:14:38 +00:00
try :
destination_hash = bytes . fromhex ( destination_hexhash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
except Exception as e :
print ( str ( e ) )
sys . exit ( 1 )
2024-08-29 11:19:39 +00:00
if not remote_link :
table = sorted ( reticulum . get_path_table ( max_hops = max_hops ) , key = lambda e : ( e [ " interface " ] , e [ " hops " ] ) )
else :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Sending request... " , end = " " )
sys . stdout . flush ( )
receipt = remote_link . request ( " /path " , data = [ " table " , destination_hash , max_hops ] )
while not receipt . concluded ( ) :
time . sleep ( 0.1 )
response = receipt . get_response ( )
if response :
table = response
print ( " \r \r " , end = " " )
else :
if not no_output :
print ( " \r \r " , end = " " )
print ( " The remote request failed. Likely authentication failure. " )
exit ( 10 )
2021-09-24 10:42:24 +00:00
2022-05-14 20:14:38 +00:00
displayed = 0
2024-08-29 12:51:38 +00:00
if json :
import json
for p in table :
for k in p :
if isinstance ( p [ k ] , bytes ) :
p [ k ] = RNS . hexrep ( p [ k ] , delimit = False )
print ( json . dumps ( table ) )
exit ( )
else :
for path in table :
if destination_hash == None or destination_hash == path [ " hash " ] :
displayed + = 1
exp_str = RNS . timestamp_str ( path [ " expires " ] )
if path [ " hops " ] == 1 :
m_str = " "
else :
m_str = " s "
2024-10-07 08:44:18 +00:00
print ( f " { RNS . prettyhexrep ( path [ ' hash ' ] ) } is { path [ ' hops ' ] } hop { m_str } away via { RNS . prettyhexrep ( path [ ' via ' ] ) } on { path [ ' interface ' ] } expires { RNS . timestamp_str ( path [ ' expires ' ] ) } " )
2022-05-14 20:14:38 +00:00
2024-08-29 12:51:38 +00:00
if destination_hash != None and displayed == 0 :
print ( " No path known " )
sys . exit ( 1 )
2022-05-14 20:14:38 +00:00
elif rates :
destination_hash = None
if destination_hexhash != None :
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( destination_hexhash ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2022-05-14 20:14:38 +00:00
try :
destination_hash = bytes . fromhex ( destination_hexhash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
except Exception as e :
print ( str ( e ) )
sys . exit ( 1 )
2024-08-29 11:19:39 +00:00
if not remote_link :
table = reticulum . get_rate_table ( )
else :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Sending request... " , end = " " )
sys . stdout . flush ( )
receipt = remote_link . request ( " /path " , data = [ " rates " , destination_hash ] )
while not receipt . concluded ( ) :
time . sleep ( 0.1 )
response = receipt . get_response ( )
if response :
table = response
print ( " \r \r " , end = " " )
else :
if not no_output :
print ( " \r \r " , end = " " )
print ( " The remote request failed. Likely authentication failure. " )
exit ( 10 )
2022-05-14 20:14:38 +00:00
2024-08-29 11:19:39 +00:00
table = sorted ( table , key = lambda e : e [ " last " ] )
2024-08-29 12:51:38 +00:00
if json :
import json
for p in table :
for k in p :
if isinstance ( p [ k ] , bytes ) :
p [ k ] = RNS . hexrep ( p [ k ] , delimit = False )
print ( json . dumps ( table ) )
exit ( )
2022-05-14 20:14:38 +00:00
else :
2024-08-29 12:51:38 +00:00
if len ( table ) == 0 :
print ( " No information available " )
else :
displayed = 0
for entry in table :
if destination_hash == None or destination_hash == entry [ " hash " ] :
displayed + = 1
try :
last_str = pretty_date ( int ( entry [ " last " ] ) )
start_ts = entry [ " timestamps " ] [ 0 ]
span = max ( time . time ( ) - start_ts , 3600.0 )
span_hours = span / 3600.0
span_str = pretty_date ( int ( entry [ " timestamps " ] [ 0 ] ) )
hour_rate = round ( len ( entry [ " timestamps " ] ) / span_hours , 3 )
if hour_rate - int ( hour_rate ) == 0 :
hour_rate = int ( hour_rate )
2024-10-07 08:52:43 +00:00
2024-08-29 12:51:38 +00:00
if entry [ " rate_violations " ] > 0 :
if entry [ " rate_violations " ] == 1 :
s_str = " "
else :
s_str = " s "
2024-10-07 08:44:18 +00:00
rv_str = f " , { entry [ ' rate_violations ' ] } active rate violation { s_str } "
2024-08-29 12:51:38 +00:00
else :
rv_str = " "
2024-10-07 08:52:43 +00:00
2024-08-29 12:51:38 +00:00
if entry [ " blocked_until " ] > time . time ( ) :
bli = time . time ( ) - ( int ( entry [ " blocked_until " ] ) - time . time ( ) )
2024-10-07 08:44:18 +00:00
bl_str = f " , new announces allowed in { pretty_date ( int ( bli ) ) } "
2022-05-14 20:14:38 +00:00
else :
2024-08-29 12:51:38 +00:00
bl_str = " "
2022-05-14 20:14:38 +00:00
2024-10-07 08:52:43 +00:00
2024-10-07 08:44:18 +00:00
print ( f " { RNS . prettyhexrep ( entry [ ' hash ' ] ) } last heard { last_str } ago, { hour_rate } announces/hour in the last { span_str } { rv_str } { bl_str } " )
2022-05-14 20:14:38 +00:00
2024-08-29 12:51:38 +00:00
except Exception as e :
2024-10-07 08:44:18 +00:00
print ( f " Error while processing entry for { RNS . prettyhexrep ( entry [ ' hash ' ] ) } " )
2024-08-29 12:51:38 +00:00
print ( str ( e ) )
2022-05-14 20:14:38 +00:00
2024-08-29 12:51:38 +00:00
if destination_hash != None and displayed == 0 :
print ( " No information available " )
sys . exit ( 1 )
2021-09-24 12:16:25 +00:00
2022-05-13 14:18:13 +00:00
elif drop_queues :
2024-08-29 12:51:38 +00:00
if remote_link :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Dropping announce queues on remote instances not yet implemented " )
exit ( 255 )
print ( " Dropping announce queues on all interfaces... " )
2022-05-13 14:18:13 +00:00
reticulum . drop_announce_queues ( )
2024-10-07 08:52:43 +00:00
2022-04-20 09:12:21 +00:00
elif drop :
2024-08-29 12:51:38 +00:00
if remote_link :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Dropping path on remote instances not yet implemented " )
exit ( 255 )
2022-04-20 09:12:21 +00:00
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( destination_hexhash ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2022-04-20 09:12:21 +00:00
try :
destination_hash = bytes . fromhex ( destination_hexhash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
except Exception as e :
print ( str ( e ) )
2022-05-14 20:14:38 +00:00
sys . exit ( 1 )
2022-04-20 09:12:21 +00:00
if reticulum . drop_path ( destination_hash ) :
2024-10-07 08:44:18 +00:00
print ( f " Dropped path to { RNS . prettyhexrep ( destination_hash ) } " )
2022-04-20 09:12:21 +00:00
else :
2024-10-07 08:44:18 +00:00
print ( f " Unable to drop path to { RNS . prettyhexrep ( destination_hash ) } . Does it exist? " )
2022-05-14 20:14:38 +00:00
sys . exit ( 1 )
2023-10-01 09:39:07 +00:00
elif drop_via :
2024-08-29 12:51:38 +00:00
if remote_link :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Dropping all paths via specific transport instance on remote instances yet not implemented " )
exit ( 255 )
2023-10-01 09:39:07 +00:00
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( destination_hexhash ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2023-10-01 09:39:07 +00:00
try :
destination_hash = bytes . fromhex ( destination_hexhash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
except Exception as e :
print ( str ( e ) )
sys . exit ( 1 )
if reticulum . drop_all_via ( destination_hash ) :
2024-10-07 08:44:18 +00:00
print ( f " Dropped all paths via { RNS . prettyhexrep ( destination_hash ) } " )
2023-10-01 09:39:07 +00:00
else :
2024-10-07 08:44:18 +00:00
print ( f " Unable to drop paths via { RNS . prettyhexrep ( destination_hash ) } . Does the transport instance exist? " )
2023-10-01 09:39:07 +00:00
sys . exit ( 1 )
2021-09-24 13:34:03 +00:00
else :
2024-08-29 12:51:38 +00:00
if remote_link :
if not no_output :
print ( " \r \r " , end = " " )
print ( " Requesting paths on remote instances not implemented " )
exit ( 255 )
2022-04-20 08:40:51 +00:00
try :
dest_len = ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2
if len ( destination_hexhash ) != dest_len :
2024-10-07 08:44:18 +00:00
raise ValueError ( f " Destination length is invalid, must be { dest_len } hexadecimal characters ( { dest_len / / 2 } bytes). " )
2022-04-20 08:40:51 +00:00
try :
destination_hash = bytes . fromhex ( destination_hexhash )
except Exception as e :
raise ValueError ( " Invalid destination entered. Check your input. " )
except Exception as e :
print ( str ( e ) )
2022-05-14 20:14:38 +00:00
sys . exit ( 1 )
2024-10-07 08:52:43 +00:00
2022-04-20 08:40:51 +00:00
if not RNS . Transport . has_path ( destination_hash ) :
RNS . Transport . request_path ( destination_hash )
2024-10-07 08:44:18 +00:00
print ( f " Path to { RNS . prettyhexrep ( destination_hash ) } requested " , end = " " )
2022-04-20 08:40:51 +00:00
sys . stdout . flush ( )
i = 0
syms = " ⢄⢂⢁⡁⡈⡐⡠ "
2022-04-20 11:40:07 +00:00
limit = time . time ( ) + timeout
while not RNS . Transport . has_path ( destination_hash ) and time . time ( ) < limit :
2022-04-20 08:40:51 +00:00
time . sleep ( 0.1 )
2024-10-07 08:44:18 +00:00
print ( f " \b \b { syms [ i ] } " , end = " " )
2022-04-20 08:40:51 +00:00
sys . stdout . flush ( )
i = ( i + 1 ) % len ( syms )
2022-04-20 11:40:07 +00:00
if RNS . Transport . has_path ( destination_hash ) :
hops = RNS . Transport . hops_to ( destination_hash )
2023-09-13 11:02:05 +00:00
next_hop_bytes = reticulum . get_next_hop ( destination_hash )
if next_hop_bytes == None :
print ( " \r \r Error: Invalid path data returned " )
sys . exit ( 1 )
2022-04-20 11:40:07 +00:00
else :
2023-09-13 11:02:05 +00:00
next_hop = RNS . prettyhexrep ( next_hop_bytes )
next_hop_interface = reticulum . get_next_hop_if_name ( destination_hash )
if hops != 1 :
ms = " s "
else :
ms = " "
2021-09-24 13:34:03 +00:00
2024-10-07 08:44:18 +00:00
print ( f ' \r Path found, destination { RNS . prettyhexrep ( destination_hash ) } is { hops } hop { ms } away via { next_hop } on { next_hop_interface } ' )
2022-04-20 11:40:07 +00:00
else :
2022-09-13 22:07:23 +00:00
print ( " \r \r Path not found " )
2022-05-14 20:14:38 +00:00
sys . exit ( 1 )
2024-10-07 08:52:43 +00:00
2021-09-24 10:42:24 +00:00
def main ( ) :
try :
parser = argparse . ArgumentParser ( description = " Reticulum Path Discovery Utility " )
parser . add_argument ( " --config " ,
action = " store " ,
default = None ,
help = " path to alternative Reticulum config directory " ,
type = str
)
parser . add_argument (
" --version " ,
action = " version " ,
2024-10-07 08:44:18 +00:00
version = f " rnpath { __version__ } "
2021-09-24 10:42:24 +00:00
)
2022-04-20 08:40:51 +00:00
parser . add_argument (
" -t " ,
" --table " ,
action = " store_true " ,
help = " show all known paths " ,
default = False
)
2024-08-29 09:17:07 +00:00
parser . add_argument (
" -m " ,
" --max " ,
action = " store " ,
metavar = " hops " ,
type = int ,
help = " maximum hops to filter path table by " ,
default = None
)
2022-05-14 20:14:38 +00:00
parser . add_argument (
" -r " ,
" --rates " ,
action = " store_true " ,
help = " show announce rate info " ,
default = False
)
2022-04-20 09:12:21 +00:00
parser . add_argument (
" -d " ,
" --drop " ,
action = " store_true " ,
help = " remove the path to a destination " ,
default = False
)
2022-05-13 14:18:13 +00:00
parser . add_argument (
" -D " ,
" --drop-announces " ,
action = " store_true " ,
help = " drop all queued announces " ,
default = False
)
2023-10-01 09:39:07 +00:00
parser . add_argument (
" -x " , " --drop-via " ,
action = " store_true " ,
help = " drop all paths via specified transport instance " ,
default = False
)
2022-04-20 11:40:07 +00:00
parser . add_argument (
" -w " ,
action = " store " ,
metavar = " seconds " ,
type = float ,
help = " timeout before giving up " ,
2022-05-22 12:18:58 +00:00
default = RNS . Transport . PATH_REQUEST_TIMEOUT
2022-04-20 11:40:07 +00:00
)
2024-08-29 11:19:39 +00:00
parser . add_argument (
" -R " ,
action = " store " ,
metavar = " hash " ,
help = " transport identity hash of remote instance to manage " ,
default = None ,
type = str
)
parser . add_argument (
" -i " ,
action = " store " ,
metavar = " path " ,
help = " path to identity used for remote management " ,
default = None ,
type = str
)
parser . add_argument (
" -W " ,
action = " store " ,
metavar = " seconds " ,
type = float ,
help = " timeout before giving up on remote queries " ,
default = RNS . Transport . PATH_REQUEST_TIMEOUT
)
2024-10-07 08:52:43 +00:00
2024-08-29 12:51:38 +00:00
parser . add_argument (
" -j " ,
" --json " ,
action = " store_true " ,
help = " output in JSON format " ,
default = False
)
2024-08-29 11:19:39 +00:00
2021-09-24 10:42:24 +00:00
parser . add_argument (
" destination " ,
nargs = " ? " ,
default = None ,
help = " hexadecimal hash of the destination " ,
type = str
)
2021-09-24 13:34:03 +00:00
parser . add_argument ( ' -v ' , ' --verbose ' , action = ' count ' , default = 0 )
2024-10-07 08:52:43 +00:00
2021-09-24 10:42:24 +00:00
args = parser . parse_args ( )
if args . config :
configarg = args . config
else :
configarg = None
2023-10-01 09:39:07 +00:00
if not args . drop_announces and not args . table and not args . rates and not args . destination and not args . drop_via :
2021-09-24 10:42:24 +00:00
print ( " " )
parser . print_help ( )
print ( " " )
else :
2022-04-20 09:12:21 +00:00
program_setup (
configdir = configarg ,
table = args . table ,
2022-05-14 20:14:38 +00:00
rates = args . rates ,
2022-04-20 09:12:21 +00:00
drop = args . drop ,
destination_hexhash = args . destination ,
2022-04-20 11:40:07 +00:00
verbosity = args . verbose ,
timeout = args . w ,
2022-05-13 14:18:13 +00:00
drop_queues = args . drop_announces ,
2023-10-01 09:39:07 +00:00
drop_via = args . drop_via ,
2024-08-29 09:17:07 +00:00
max_hops = args . max ,
2024-08-29 11:19:39 +00:00
remote = args . R ,
management_identity = args . i ,
remote_timeout = args . W ,
2024-08-29 12:51:38 +00:00
json = args . json ,
2022-04-20 09:12:21 +00:00
)
2022-05-14 20:14:38 +00:00
sys . exit ( 0 )
2021-09-24 10:42:24 +00:00
except KeyboardInterrupt :
print ( " " )
exit ( )
2022-05-14 20:14:38 +00:00
def pretty_date ( time = False ) :
from datetime import datetime
now = datetime . now ( )
if type ( time ) is int :
diff = now - datetime . fromtimestamp ( time )
elif isinstance ( time , datetime ) :
diff = now - time
elif not time :
diff = now - now
second_diff = diff . seconds
day_diff = diff . days
if day_diff < 0 :
return ' '
if day_diff == 0 :
if second_diff < 10 :
2024-10-07 08:44:18 +00:00
return f " { second_diff } seconds "
2022-05-14 20:14:38 +00:00
if second_diff < 60 :
2024-10-07 08:44:18 +00:00
return f " { second_diff } seconds "
2022-05-14 20:14:38 +00:00
if second_diff < 120 :
return " 1 minute "
if second_diff < 3600 :
2024-10-07 08:44:18 +00:00
return f " { int ( second_diff / 60 ) } minutes "
2022-05-14 20:14:38 +00:00
if second_diff < 7200 :
2022-05-17 22:47:29 +00:00
return " an hour "
2022-05-14 20:14:38 +00:00
if second_diff < 86400 :
2024-10-07 08:44:18 +00:00
return f " { int ( second_diff / 3600 ) } hours "
2022-05-14 20:14:38 +00:00
if day_diff == 1 :
return " 1 day "
if day_diff < 7 :
2024-10-07 08:44:18 +00:00
return f " { day_diff } days "
2022-05-14 20:14:38 +00:00
if day_diff < 31 :
2024-10-07 08:44:18 +00:00
return f " { int ( day_diff / 7 ) } weeks "
2022-05-14 20:14:38 +00:00
if day_diff < 365 :
2024-10-07 08:44:18 +00:00
return f " { int ( day_diff / 30 ) } months "
return f " { int ( day_diff / 365 ) } years "
2022-05-14 20:14:38 +00:00
2021-09-24 10:42:24 +00:00
if __name__ == " __main__ " :
main ( )