File: cadex.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2024 pacman64
   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 above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 # cadex [amount...] [currency code...]
  27 #
  28 # The CAnaDian EXchange is a currency converter for the canadian dollar (CAD)
  29 # from/to all the major/top-traded international currencies.
  30 #
  31 # All data come straight from the Bank of Canada, and are updated at 4pm,
  32 # Monday to Friday.
  33 
  34 
  35 from datetime import datetime, timedelta
  36 from json import loads
  37 from sys import argv, exit, stderr, stdout
  38 from urllib.request import urlopen
  39 
  40 from typing import Dict, List
  41 
  42 
  43 # info is the message shown when the leading argument is one of the standard
  44 # cmd-line help options
  45 info = '''
  46 cadex [amount...] [currency code...]
  47 
  48 The CAnaDian EXchange is a currency converter for the canadian dollar (CAD)
  49 from/to all the major/top-traded international currencies.
  50 
  51 All data come straight from the Bank of Canada, and are updated at 4pm,
  52 Monday to Friday.
  53 '''.strip()
  54 
  55 # a leading help-option arg means show the help message and quit
  56 if len(argv) == 2 and argv[1].lower() in ('-h', '--h', '-help', '--help'):
  57     print(info, file=stderr)
  58     exit(0)
  59 
  60 
  61 name_aliases: Dict[strstr] = {
  62     'AU': 'AUD',
  63     'BR': 'BRL',
  64     'CH': 'CHF',
  65     'CN': 'CNY',
  66     'EU': 'EUR',
  67     'EURO': 'EUR',
  68     'GB': 'GBP',
  69     'HK': 'HKD',
  70     'ID': 'IDR',
  71     'IN': 'INR',
  72     'JP': 'JPY',
  73     'KR': 'KRW',
  74     'ME': 'MXN',
  75     'MX': 'MXN',
  76     'NO': 'NOK',
  77     'NZ': 'NZD',
  78     'PE': 'PEN',
  79     'RU': 'RUB',
  80     'SA': 'SAR',
  81     'SE': 'SEK',
  82     'SG': 'SGD',
  83     'TR': 'TRY',
  84     'TW': 'TWD',
  85     'UK': 'GBP',
  86     'US': 'USD',
  87     'USA': 'USD',
  88     'ZA': 'ZAR',
  89 }
  90 
  91 # which currencies to convert, and the order in which to present them in the output table
  92 cur_list: List[str] = [
  93     'USD', 'MXN', 'EUR', 'GBP', 'AUD', 'CNY', 'INR', 'CHF',  'BRL', 'HKD', 'IDR',
  94     'JPY', 'NZD', 'NOK', 'PEN', 'RUB', 'SAR', 'SGD', 'ZAR', 'KRW', 'SEK', 'TWD',
  95     'TRY'
  96 ]
  97 
  98 cur_names: Dict[strstr] = {
  99     'USD': 'US Dollar',
 100     'MXN': 'Mexican Peso',
 101     'EUR': 'Euro',
 102     'GBP': 'British Pound',
 103     'AUD': 'Australian Dollar',
 104     'CNY': 'Chinese Yuan',
 105     'INR': 'Indian Rupee',
 106     'CHF': 'Swiss Franc',
 107     'BRL': 'Brazilian Real',
 108     'HKD': 'Hong Kong Dollar',
 109     'IDR': 'Indonesian Rupiah',
 110     'JPY': 'Japanese Yen',
 111     'NZD': 'New Zealand Dollar',
 112     'NOK': 'Norwegian Korona',
 113     'PEN': 'Peruvian New Sol',
 114     'RUB': 'Russian Ruble',
 115     'SAR': 'Saudi Riyal',
 116     'SGD': 'Singaporean Dollar',
 117     'ZAR': 'South African Rand',
 118     'KRW': 'Korean Wong',
 119     'SEK': 'Swedish Korona',
 120     'TWD': 'Taiwanese Dollar',
 121     'TRY': 'Turkish Lira',
 122 }
 123 
 124 amount: float = 1
 125 rates: Dict[strfloat] = {}
 126 currency: str = ''
 127 
 128 for v in argv[1:]:
 129     try:
 130         amount = float(eval(v))
 131     except:
 132         currency = v.upper()
 133         if currency in name_aliases:
 134             currency = name_aliases[currency]
 135 
 136 stdout.reconfigure(newline='\n', encoding='utf-8')
 137 
 138 # going back 3 days guarantees this script will work even on weekends, while
 139 # minimizing data-transfers from the website
 140 now = datetime.today()
 141 since = now - timedelta(days=3)
 142 
 143 #
 144 # fetch and parse the exchange rates
 145 #
 146 
 147 # start with a reassuring message saying something is happening
 148 ymd = f'{now.year}-{now.month:02}-{now.day:02}'
 149 print(f'Using latest Bank of Canada data (from {ymd})', file=stderr)
 150 stderr.flush()
 151 
 152 boc = 'https://www.bankofcanada.ca'
 153 base = boc + '/valet/observations/group/FX_RATES_DAILY/json'
 154 uri = base + f'?start_date={since.year:02}-{since.month:02}-{since.day:02}'
 155 
 156 with urlopen(uri) as c:
 157     # exchange rates for the latest day observed: a dictionary with keys like
 158     # FXAUDCAD, each of which leads to a dictionary where key 'v' gives the
 159     # exchange rate as a string
 160     data = loads(c.read())['observations'][-1]
 161     rates = {}
 162     for k in cur_list:
 163         rates[k] = data[f'FX{k}CAD']['v']
 164 
 165 # empty line to let output breathe a bit
 166 print(file=stderr)
 167 stderr.flush()
 168 
 169 header = '        from                    to              rate      name'
 170 
 171 if currency != '' and currency in cur_list:
 172     # only change between CAD and the currency given
 173     x = amount
 174     k = currency
 175     v = float(rates[k])
 176     s = cur_names[k]
 177     print(header, file=stderr)
 178     stderr.flush()
 179     print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = {x/v:15,.2f} {k} \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 180     print(f'{x:15,.2f} {k} = \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 181     exit(0)
 182 
 183 # calculate and show all currency exchanges, converting in both directions
 184 
 185 try:
 186     # exchange amounts forward
 187     print(header, file=stderr)
 188     stderr.flush()
 189     for i, k in enumerate(cur_list):
 190         x = amount
 191         v = float(rates[k])
 192         s = cur_names[k]
 193         if i%10 < 5:
 194             print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = {x/v:15,.2f} {k} \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 195         else:
 196             print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = \x1b[38;5;244m{x/v:15,.2f} {k}\x1b[0m \x1b[38;5;208m{v:15,.4f}\x1b[0m \x1b[38;5;244m{s}\x1b[0m')
 197 
 198     # empty line to let output breathe a bit
 199     print(file=stderr)
 200     stderr.flush()
 201 
 202     # exchange amounts backward
 203     print(header, file=stderr)
 204     stderr.flush()
 205     for i, k in enumerate(cur_list):
 206         x = amount
 207         v = float(rates[k])
 208         s = cur_names[k]
 209         if i%10 < 5:
 210             print(f'{x:15,.2f} {k} = \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 211         else:
 212             print(f'\x1b[38;5;244m{x:15,.2f} {k} =\x1b[0m \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m \x1b[38;5;244m{s}\x1b[0m')
 213 except BrokenPipeError:
 214     # avoid showing broken-pipe messages from stdlib, in case not all of this
 215     # script's output was needed
 216     stderr.flush()
 217     stderr.close()