swissChili | 729acd5 | 2024-03-05 11:52:45 -0500 | [diff] [blame] | 1 | #!/usr/bin/python |
| 2 | # |
| 3 | # units_cur for units, a program for updated currency exchange rates |
| 4 | # |
| 5 | # Copyright (C) 2017-2018, 2022 |
| 6 | # Free Software Foundation, Inc |
| 7 | # |
| 8 | # This program is free software; you can redistribute it and/or modify |
| 9 | # it under the terms of the GNU General Public License as published by |
| 10 | # the Free Software Foundation; either version 3 of the License, or |
| 11 | # (at your option) any later version. |
| 12 | # |
| 13 | # This program is distributed in the hope that it will be useful, |
| 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 16 | # GNU General Public License for more details. |
| 17 | # |
| 18 | # You should have received a copy of the GNU General Public License |
| 19 | # along with this program; if not, write to the Free Software |
| 20 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
| 21 | # |
| 22 | # |
| 23 | # This program was written by Adrian Mariano (adrianm@gnu.org) |
| 24 | # |
| 25 | |
| 26 | # For Python 2 & 3 compatibility |
| 27 | from __future__ import absolute_import, division, print_function |
| 28 | # |
| 29 | # |
| 30 | |
| 31 | version = '5.0' |
| 32 | |
| 33 | # Version 5.0: |
| 34 | # |
| 35 | # Rewrite to support multiple different data sources due to disappearance |
| 36 | # of the Yahoo feed. Includes support for base currency selection. |
| 37 | # |
| 38 | # Version 4.3: 20 July 2018 |
| 39 | # |
| 40 | # Validate rate data from server |
| 41 | # |
| 42 | # Version 4.2: 18 April 2018 |
| 43 | # |
| 44 | # Handle case of empty/malformed entry returned from the server |
| 45 | # |
| 46 | # Version 4.1: 30 October 2017 |
| 47 | # |
| 48 | # Fixed to include USD in the list of currency codes. |
| 49 | # |
| 50 | # Version 4: 2 October 2017 |
| 51 | # |
| 52 | # Complete rewrite to use Yahoo YQL API due to removal of TimeGenie RSS feed. |
| 53 | # Switched to requests library using JSON. One program now runs under |
| 54 | # Python 2 or Python 3. Thanks to Ray Hamel for some help with this update. |
| 55 | |
| 56 | # Normal imports |
| 57 | import requests |
| 58 | import codecs |
| 59 | import json |
| 60 | from argparse import ArgumentParser |
| 61 | from collections import OrderedDict |
| 62 | from datetime import date |
| 63 | from os import linesep |
| 64 | from os.path import getmtime |
| 65 | from sys import exit, stderr, stdout |
| 66 | |
| 67 | datestr = date.today().isoformat() |
| 68 | |
| 69 | output_dir = '' |
| 70 | |
| 71 | currency_file = output_dir + 'currency.units' |
| 72 | cpi_file = output_dir + 'cpi.units' |
| 73 | |
| 74 | # valid metals |
| 75 | |
| 76 | validmetals = ['silver','gold','platinum'] |
| 77 | |
| 78 | PRIMITIVE = '! # Base unit, the primitive unit of currency' |
| 79 | |
| 80 | # This exchange rate table lists the currency ISO 4217 codes, their |
| 81 | # long text names, and any fixed definitions. If the definition is |
| 82 | # empty then units_cur will query the server for a value. |
| 83 | |
| 84 | rate_index = 1 |
| 85 | currency = OrderedDict([ |
| 86 | ('ATS', ['austriaschilling', '1|13.7603 euro']), |
| 87 | ('BEF', ['belgiumfranc', '1|40.3399 euro']), |
| 88 | ('CYP', ['cypruspound', '1|0.585274 euro']), |
| 89 | ('EEK', ['estoniakroon', '1|15.6466 euro # Equal to 1|8 germanymark']), |
| 90 | ('FIM', ['finlandmarkka', '1|5.94573 euro']), |
| 91 | ('FRF', ['francefranc', '1|6.55957 euro']), |
| 92 | ('DEM', ['germanymark', '1|1.95583 euro']), |
| 93 | ('GRD', ['greecedrachma', '1|340.75 euro']), |
| 94 | ('IEP', ['irelandpunt', '1|0.787564 euro']), |
| 95 | ('ITL', ['italylira', '1|1936.27 euro']), |
| 96 | ('LVL', ['latvialats', '1|0.702804 euro']), |
| 97 | ('LTL', ['lithuanialitas', '1|3.4528 euro']), |
| 98 | ('LUF', ['luxembourgfranc', '1|40.3399 euro']), |
| 99 | ('MTL', ['maltalira', '1|0.4293 euro']), |
| 100 | ('SKK', ['slovakiakoruna', '1|30.1260 euro']), |
| 101 | ('SIT', ['sloveniatolar', '1|239.640 euro']), |
| 102 | ('ESP', ['spainpeseta', '1|166.386 euro']), |
| 103 | ('NLG', ['netherlandsguilder','1|2.20371 euro']), |
| 104 | ('PTE', ['portugalescudo', '1|200.482 euro']), |
| 105 | ('CVE', ['capeverdeescudo', '1|110.265 euro']), |
| 106 | ('BGN', ['bulgarialev', '1|1.9558 euro']), |
| 107 | ('BAM', ['bosniaconvertiblemark','germanymark']), |
| 108 | ('KMF', ['comorosfranc', '1|491.96775 euro']), |
| 109 | ('XOF', ['westafricafranc', '1|655.957 euro']), |
| 110 | ('XPF', ['cfpfranc', '1|119.33 euro']), |
| 111 | ('XAF', ['centralafricacfafranc','1|655.957 euro']), |
| 112 | ('AED', ['uaedirham','']), |
| 113 | ('AFN', ['afghanistanafghani','']), |
| 114 | ('ALL', ['albanialek','']), |
| 115 | ('AMD', ['armeniadram','']), |
| 116 | ('ANG', ['antillesguilder','']), |
| 117 | ('AOA', ['angolakwanza','']), |
| 118 | ('ARS', ['argentinapeso','']), |
| 119 | ('AUD', ['australiadollar','']), |
| 120 | ('AWG', ['arubaflorin','']), |
| 121 | ('AZN', ['azerbaijanmanat','']), |
| 122 | ('BAM', ['bosniaconvertiblemark','']), |
| 123 | ('BBD', ['barbadosdollar','']), |
| 124 | ('BDT', ['bangladeshtaka','']), |
| 125 | ('BGN', ['bulgarialev','']), |
| 126 | ('BHD', ['bahraindinar','']), |
| 127 | ('BIF', ['burundifranc','']), |
| 128 | ('BMD', ['bermudadollar','']), |
| 129 | ('BND', ['bruneidollar','']), |
| 130 | ('BOB', ['boliviaboliviano','']), |
| 131 | ('BRL', ['brazilreal','']), |
| 132 | ('BSD', ['bahamasdollar','']), |
| 133 | ('BTN', ['bhutanngultrum','']), |
| 134 | ('BWP', ['botswanapula','']), |
| 135 | ('BYN', ['belarusruble','']), |
| 136 | ('BYR', ['oldbelarusruble','1|10000 BYN']), |
| 137 | ('BZD', ['belizedollar','']), |
| 138 | ('CAD', ['canadadollar','']), |
| 139 | ('CDF', ['drcfranccongolais','']), |
| 140 | ('CHF', ['swissfranc','']), |
| 141 | ('CLP', ['chilepeso','']), |
| 142 | ('CNY', ['chinayuan','']), |
| 143 | ('COP', ['colombiapeso','']), |
| 144 | ('CRC', ['costaricacolon','']), |
| 145 | ('CUP', ['cubapeso','']), |
| 146 | ('CVE', ['capeverdeescudo','']), |
| 147 | ('CZK', ['czechiakoruna','']), |
| 148 | ('DJF', ['djiboutifranc','']), |
| 149 | ('DKK', ['denmarkkrone','']), |
| 150 | ('DOP', ['dominicanrepublicpeso','']), |
| 151 | ('DZD', ['algeriadinar','']), |
| 152 | ('EGP', ['egyptpound','']), |
| 153 | ('ERN', ['eritreanakfa','']), |
| 154 | ('ETB', ['ethiopiabirr','']), |
| 155 | ('EUR', ['euro','']), |
| 156 | ('FJD', ['fijidollar','']), |
| 157 | ('FKP', ['falklandislandspound','']), |
| 158 | ('GBP', ['ukpound','']), |
| 159 | ('GEL', ['georgialari','']), |
| 160 | ('GHS', ['ghanacedi','']), |
| 161 | ('GIP', ['gibraltarpound','']), |
| 162 | ('GMD', ['gambiadalasi','']), |
| 163 | ('GNF', ['guineafranc','']), |
| 164 | ('GTQ', ['guatemalaquetzal','']), |
| 165 | ('GYD', ['guyanadollar','']), |
| 166 | ('HKD', ['hongkongdollar','']), |
| 167 | ('HNL', ['honduraslempira','']), |
| 168 | ('HRK', ['croatiakuna','']), |
| 169 | ('HTG', ['haitigourde','']), |
| 170 | ('HUF', ['hungaryforint','']), |
| 171 | ('IDR', ['indonesiarupiah','']), |
| 172 | ('ILS', ['israelnewshekel','']), |
| 173 | ('INR', ['indiarupee','']), |
| 174 | ('IQD', ['iraqdinar','']), |
| 175 | ('IRR', ['iranrial','']), |
| 176 | ('ISK', ['icelandkrona','']), |
| 177 | ('JMD', ['jamaicadollar','']), |
| 178 | ('JOD', ['jordandinar','']), |
| 179 | ('JPY', ['japanyen','']), |
| 180 | ('KES', ['kenyaschilling','']), |
| 181 | ('KGS', ['kyrgyzstansom','']), |
| 182 | ('KHR', ['cambodiariel','']), |
| 183 | ('KMF', ['comorosfranc','']), |
| 184 | ('KPW', ['northkoreawon','']), |
| 185 | ('KRW', ['southkoreawon','']), |
| 186 | ('KWD', ['kuwaitdinar','']), |
| 187 | ('KYD', ['caymanislandsdollar','']), |
| 188 | ('KZT', ['kazakhstantenge','']), |
| 189 | ('LAK', ['laokip','']), |
| 190 | ('LBP', ['lebanonpound','']), |
| 191 | ('LKR', ['srilankarupee','']), |
| 192 | ('LRD', ['liberiadollar','']), |
| 193 | ('LSL', ['lesotholoti','']), |
| 194 | ('LYD', ['libyadinar','']), |
| 195 | ('MAD', ['moroccodirham','']), |
| 196 | ('MDL', ['moldovaleu','']), |
| 197 | ('MGA', ['madagascarariary','']), |
| 198 | ('MKD', ['macedoniadenar','']), |
| 199 | ('MMK', ['myanmarkyat','']), |
| 200 | ('MNT', ['mongoliatugrik','']), |
| 201 | ('MOP', ['macaupataca','']), |
| 202 | ('MRO', ['mauritaniaoldouguiya','1|10 MRU']), |
| 203 | ('MRU', ['mauritaniaouguiya', '']), |
| 204 | ('MUR', ['mauritiusrupee','']), |
| 205 | ('MVR', ['maldiverufiyaa','']), |
| 206 | ('MWK', ['malawikwacha','']), |
| 207 | ('MXN', ['mexicopeso','']), |
| 208 | ('MYR', ['malaysiaringgit','']), |
| 209 | ('MZN', ['mozambiquemetical','']), |
| 210 | ('NAD', ['namibiadollar','']), |
| 211 | ('NGN', ['nigerianaira','']), |
| 212 | ('NIO', ['nicaraguacordobaoro','']), |
| 213 | ('NOK', ['norwaykrone','']), |
| 214 | ('NPR', ['nepalrupee','']), |
| 215 | ('NZD', ['newzealanddollar','']), |
| 216 | ('OMR', ['omanrial','']), |
| 217 | ('PAB', ['panamabalboa','']), |
| 218 | ('PEN', ['perunuevosol','']), |
| 219 | ('PGK', ['papuanewguineakina','']), |
| 220 | ('PHP', ['philippinepeso','']), |
| 221 | ('PKR', ['pakistanrupee','']), |
| 222 | ('PLN', ['polandzloty','']), |
| 223 | ('PYG', ['paraguayguarani','']), |
| 224 | ('QAR', ['qatarrial','']), |
| 225 | ('RON', ['romanianewlei','']), |
| 226 | ('RSD', ['serbiadinar','']), |
| 227 | ('RUB', ['russiaruble','']), |
| 228 | ('RWF', ['rwandafranc','']), |
| 229 | ('SAR', ['saudiarabiariyal','']), |
| 230 | ('SBD', ['solomonislandsdollar','']), |
| 231 | ('SCR', ['seychellesrupee','']), |
| 232 | ('SDG', ['sudanpound','']), |
| 233 | ('SEK', ['swedenkrona','']), |
| 234 | ('SGD', ['singaporedollar','']), |
| 235 | ('SHP', ['sainthelenapound','']), |
| 236 | # ('SLL', ['sierraleoneoldleone','1|1000 SLE']), |
| 237 | # ('SLE', ['sierraleoneleone','']), |
| 238 | ('SOS', ['somaliaschilling','']), |
| 239 | ('SRD', ['surinamedollar','']), |
| 240 | ('SSP', ['southsudanpound','']), |
| 241 | ('STD', ['saotome&principeolddobra','']), |
| 242 | ('STN', ['saotome&principedobra','']), |
| 243 | # ('SVC', ['elsalvadorcolon','']), # eliminated in 2001 |
| 244 | ('SYP', ['syriapound','']), |
| 245 | ('SZL', ['swazilandlilangeni','']), |
| 246 | ('THB', ['thailandbaht','']), |
| 247 | ('TJS', ['tajikistansomoni','']), |
| 248 | ('TMT', ['turkmenistanmanat','']), |
| 249 | ('TND', ['tunisiadinar','']), |
| 250 | ('TOP', ["tongapa'anga",'']), |
| 251 | ('TRY', ['turkeylira','']), |
| 252 | ('TTD', ['trinidadandtobagodollar','']), |
| 253 | ('TWD', ['taiwandollar','']), |
| 254 | ('TZS', ['tanzaniashilling','']), |
| 255 | ('UAH', ['ukrainehryvnia','']), |
| 256 | ('UGX', ['ugandaschilling','']), |
| 257 | ('USD', ['US$', '']), |
| 258 | ('UYU', ['uruguaypeso','']), |
| 259 | ('UZS', ['uzbekistansum','']), |
| 260 | ('VEF', ['venezuelabolivarfuerte','']), |
| 261 | ('VES', ['venezuelabolivarsoberano','']), |
| 262 | ('VND', ['vietnamdong','']), |
| 263 | ('VUV', ['vanuatuvatu','']), |
| 264 | ('WST', ['samoatala','']), |
| 265 | ('XAF', ['centralafricacfafranc','']), |
| 266 | ('XCD', ['eastcaribbeandollar','']), |
| 267 | ('XDR', ['specialdrawingrights','']), |
| 268 | ('YER', ['yemenrial','']), |
| 269 | ('ZAR', ['southafricarand','']), |
| 270 | ('ZMW', ['zambiakwacha','']), |
| 271 | ('ZWL', ['zimbabwedollar','']), |
| 272 | ('FOK', ['faroeislandskróna','DKK']), |
| 273 | ('GGP', ['guernseypound', 'GBP']), |
| 274 | ('IMP', ['isleofmanpound','GBP']), |
| 275 | ('JEP', ['jerseypound','GBP']), |
| 276 | ('KID', ['kiribatidollar','AUD']), |
| 277 | ('TVD', ['tuvaludollar','AUD']), |
| 278 | ]) |
| 279 | |
| 280 | |
| 281 | def validfloat(x): |
| 282 | try: |
| 283 | float(x) |
| 284 | return True |
| 285 | except ValueError: |
| 286 | return False |
| 287 | |
| 288 | def addrate(verbose,form,code,rate): |
| 289 | if code not in currency.keys(): |
| 290 | if (verbose): |
| 291 | stderr.write('Got unknown currency with code {}\n'.format(code)) |
| 292 | else: |
| 293 | if not currency[code][rate_index]: |
| 294 | if validfloat(rate): |
| 295 | currency[code][rate_index] = form.format(rate) |
| 296 | else: |
| 297 | stderr.write('Got invalid rate "{}" for currency "{}"\n'.format( |
| 298 | rate, code)) |
| 299 | elif verbose: |
| 300 | if currency[code][rate_index] != form.format(rate): |
| 301 | stderr.write('Got value "{}" for currency "{}" but ' |
| 302 | 'it is already defined as {}\n'.format(rate, code, |
| 303 | currency[code][rate_index])) |
| 304 | |
| 305 | def getjson(address,args=None): |
| 306 | try: |
| 307 | res = requests.get(address,args) |
| 308 | res.raise_for_status() |
| 309 | return(res.json()) |
| 310 | except requests.exceptions.RequestException as e: |
| 311 | stderr.write('Error connecting to currency server:\n{}.\n'.format(e)) |
| 312 | exit(1) |
| 313 | |
| 314 | ######################################################## |
| 315 | # |
| 316 | # Connect to floatrates for currency update |
| 317 | # |
| 318 | |
| 319 | def floatrates(verbose,base,dummy): |
| 320 | webdata = getjson('https://www.floatrates.com/daily/'+base+'.json') |
| 321 | for index in webdata: |
| 322 | entry = webdata[index] |
| 323 | if 'rate' not in entry or 'code' not in entry: # Skip empty/bad entries |
| 324 | if verbose: |
| 325 | stderr.write('Got bad entry from server: '+str(entry)+'\n') |
| 326 | else: |
| 327 | addrate(verbose,'{} '+base,entry['code'],entry['inverseRate']) |
| 328 | currency[base][rate_index] = PRIMITIVE |
| 329 | return('FloatRates ('+base+' base)') |
| 330 | |
| 331 | ######################################################## |
| 332 | # |
| 333 | # Connect to European central bank site |
| 334 | # |
| 335 | |
| 336 | def eubankrates(verbose,base,dummy): |
| 337 | if verbose and base!='EUR': |
| 338 | stderr.write('European bank uses euro for base currency. Specified base {} ignored.\n'.format(base)) |
| 339 | import xml.etree.ElementTree as ET |
| 340 | try: |
| 341 | res=requests.get('https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml') |
| 342 | res.raise_for_status() |
| 343 | data = ET.fromstring(res.content)[2][0] |
| 344 | except requests.exceptions.RequestException as e: |
| 345 | stderr.write('Error connecting to currency server:\n{}.\n'. |
| 346 | format(e)) |
| 347 | exit(1) |
| 348 | for entry in data.iter(): |
| 349 | if entry.get('time'): |
| 350 | continue |
| 351 | rate = entry.get('rate') |
| 352 | code = entry.get('currency') |
| 353 | if not rate or not code: # Skip empty/bad entries |
| 354 | if verbose: |
| 355 | stderr.write('Got bad entry from server, code {} and rate {}\n'.format(code,rate)) |
| 356 | else: |
| 357 | addrate(verbose,'1|{} euro', code, rate) |
| 358 | currency['EUR'][rate_index]=PRIMITIVE |
| 359 | return('the European Central Bank (euro base)') |
| 360 | |
| 361 | ######################################################## |
| 362 | # |
| 363 | # Connect to fixer.io (requires API key) |
| 364 | # |
| 365 | # Free API key does not allow changing base currency |
| 366 | # With free key only euro base is supported, and https is not allowed |
| 367 | # |
| 368 | |
| 369 | def fixer(verbose,base,key): |
| 370 | if not key: |
| 371 | stderr.write('API key required for this source\n') |
| 372 | exit(1) |
| 373 | if verbose and base!='EUR': |
| 374 | stderr.write('Fixer uses euro for base currency. Specified base {} ignored.\n'.format(base)) |
| 375 | webdata = getjson('http://data.fixer.io/api/latest', {'access_key':key}) |
| 376 | if not webdata['success']: |
| 377 | stderr.write('Currency server error: '+webdata['error']['info']) |
| 378 | exit(1) |
| 379 | for code in webdata['rates']: |
| 380 | addrate(verbose,'1|{} euro', code, webdata['rates'][code]) |
| 381 | currency['EUR'][rate_index] = PRIMITIVE |
| 382 | return('Fixer (euro base)') |
| 383 | |
| 384 | ######################################################## |
| 385 | # |
| 386 | # Connect to openexchangerates (requires API key) |
| 387 | # |
| 388 | # Free API key does not allow changing the base currency |
| 389 | # |
| 390 | |
| 391 | def openexchangerates(verbose,base,key): |
| 392 | if not key: |
| 393 | stderr.write('API key required for this source\n') |
| 394 | exit(1) |
| 395 | if verbose and base!='USD': |
| 396 | stderr.write('Open Exchange Rates uses US dollar for base currency. Specified base {} ignored.\n'.format(base)) |
| 397 | webdata = getjson('https://openexchangerates.org/api/latest.json', |
| 398 | {'app_id':key} |
| 399 | ) |
| 400 | for code in webdata['rates']: |
| 401 | addrate(verbose,'1|{} US$', code, webdata['rates'][code]) |
| 402 | currency['USD'][rate_index] = PRIMITIVE |
| 403 | return('open exchange rates (USD base)') |
| 404 | |
| 405 | ######################################################## |
| 406 | # |
| 407 | # Connect to exchangerate-api.com |
| 408 | # |
| 409 | # The open access endpoint gives updates once per day. |
| 410 | # User can optionally supply a paid account's API key to get newer data and more precision. |
| 411 | # Base currency can be changed with both open and paid versions of the API. |
| 412 | # |
| 413 | |
| 414 | def exchangerate_api(verbose,base,key): |
| 415 | if not key: |
| 416 | webdata = getjson('https://open.er-api.com/v6/latest/'+base) |
| 417 | if not webdata['result'] or webdata['result'] != 'success': |
| 418 | stderr.write('Currency server error: '+webdata['error-type']+'\n') |
| 419 | exit(1) |
| 420 | for code, rate in webdata['rates'].items() : |
| 421 | addrate(verbose,'1|{} '+base,code,rate) |
| 422 | else: |
| 423 | webdata = getjson('https://v6.exchangerate-api.com/v6/'+key+'/latest/'+base) |
| 424 | if not webdata['result'] or webdata['result'] != 'success': |
| 425 | stderr.write('Currency server error: '+webdata['error-type']+'\n') |
| 426 | exit(1) |
| 427 | for code, rate in webdata['conversion_rates'].items() : |
| 428 | addrate(verbose,'1|{} '+base,code,rate) |
| 429 | currency[base][rate_index] = PRIMITIVE |
| 430 | return('exchangerate-api.com ('+base+' base)') |
| 431 | |
| 432 | |
| 433 | ####################################################### |
| 434 | # |
| 435 | # list of valid source names and corresponding functions |
| 436 | # |
| 437 | |
| 438 | sources = { |
| 439 | 'exchangerate_api': exchangerate_api, |
| 440 | 'floatrates': floatrates, |
| 441 | 'eubank' : eubankrates, |
| 442 | 'fixer' : fixer, |
| 443 | 'openexchangerates': openexchangerates, |
| 444 | } |
| 445 | |
| 446 | default_currency = 'exchangerate_api' |
| 447 | |
| 448 | ####################################################### |
| 449 | # |
| 450 | # Argument Processing |
| 451 | # |
| 452 | |
| 453 | ap = ArgumentParser( |
| 454 | description="Update currency information for 'units' " |
| 455 | "into the specified filenames or the default currency " |
| 456 | "file name {} and CPI file name {}. The special " |
| 457 | "filename '-' will send either or both files to " |
| 458 | "stdout.".format(currency_file,cpi_file), |
| 459 | ) |
| 460 | |
| 461 | ap.add_argument( |
| 462 | 'currency_file', |
| 463 | default=currency_file, |
| 464 | help='the file to update', |
| 465 | metavar='currency_file', |
| 466 | nargs='?', |
| 467 | type=str, |
| 468 | ) |
| 469 | |
| 470 | ap.add_argument( |
| 471 | 'cpi_file', |
| 472 | default=cpi_file, |
| 473 | help='the file to update', |
| 474 | metavar='cpi_file', |
| 475 | nargs='?', |
| 476 | type=str, |
| 477 | ) |
| 478 | |
| 479 | ap.add_argument('-V','--version', |
| 480 | action='version', |
| 481 | version='%(prog)s version ' + version, |
| 482 | help='display units_cur version', |
| 483 | ) |
| 484 | |
| 485 | ap.add_argument('-v','--verbose', |
| 486 | action='store_true', |
| 487 | help='display details when fetching currency data', |
| 488 | ) |
| 489 | |
| 490 | ap.add_argument('-s','--source',choices=list(sources.keys()), |
| 491 | default=default_currency, |
| 492 | help='set currency data source (default: {})'.format(default_currency), |
| 493 | ) |
| 494 | |
| 495 | ap.add_argument('-b','--base',default='USD', |
| 496 | help='set the base currency (when allowed by source). BASE should be a 3 letter ISO currency code, e.g. USD. The specified currency will be the primitive currency unit used by units. Only the floatrates source supports this option.', |
| 497 | ) |
| 498 | |
| 499 | ap.add_argument('-k','--key',default='', |
| 500 | help='set API key for sources that require it' |
| 501 | ) |
| 502 | |
| 503 | ap.add_argument('--blskey',default='', |
| 504 | help='set BLS key for fetching CPI data' |
| 505 | ) |
| 506 | |
| 507 | |
| 508 | args = ap.parse_args() |
| 509 | currency_file = args.currency_file |
| 510 | cpi_file = args.cpi_file |
| 511 | verbose = args.verbose |
| 512 | source = args.source |
| 513 | base = args.base |
| 514 | apikey = args.key |
| 515 | cpikey = args.blskey |
| 516 | |
| 517 | if base not in currency.keys(): |
| 518 | stderr.write('Base currency {} is not a known currency code.\n'.format(base)) |
| 519 | exit(1) |
| 520 | |
| 521 | ######################################################## |
| 522 | # |
| 523 | # Fetch currency data from specified curerncy source |
| 524 | # |
| 525 | |
| 526 | sourcename = sources[source](verbose,base,apikey) |
| 527 | |
| 528 | # Delete currencies where we have no rate data |
| 529 | for code in list(currency.keys()): |
| 530 | if not currency[code][rate_index]: |
| 531 | if verbose: |
| 532 | stderr.write('No data for {}\n'.format(code)) |
| 533 | del currency[code] |
| 534 | |
| 535 | cnames = [currency[code][0] for code in currency.keys()] |
| 536 | crates = [currency[code][1] for code in currency.keys()] |
| 537 | |
| 538 | codestr = '\n'.join('{:23}{}'. |
| 539 | format(code, name) for (code,name) in zip(currency.keys(), cnames)) |
| 540 | |
| 541 | maxlen = max(len(name) for name in cnames) + 2 |
| 542 | |
| 543 | ratestr = '\n'.join( |
| 544 | '{:{}}{}'.format(name, maxlen, rate) for (name, rate) in zip(cnames, crates) |
| 545 | ) |
| 546 | |
| 547 | ####################################################### |
| 548 | # |
| 549 | # Get precious metals data and bitcoin |
| 550 | # |
| 551 | |
| 552 | metals = getjson('https://services.packetizer.com/spotprices',{'f':'json'}) |
| 553 | bitcoin = getjson('https://services.packetizer.com/btc',{'f':'json'}) |
| 554 | |
| 555 | metallist = ['']*len(validmetals) |
| 556 | for metal, price in metals.items(): |
| 557 | if metal in validmetals: |
| 558 | metalindex = validmetals.index(metal) |
| 559 | if validfloat(price): |
| 560 | if not metallist[metalindex]: |
| 561 | metallist[validmetals.index(metal)] = '{:19}{} US$/troyounce'.format( |
| 562 | metal + 'price', price) |
| 563 | elif verbose: |
| 564 | stderr.write('Got value "{}" for metal "{}" but ' |
| 565 | 'it is already defined\n'.format(price,metal)) |
| 566 | else: |
| 567 | stderr.write('Got invalid rate "{}" for metal "{}"\n'.format(price,metal)) |
| 568 | elif metal != 'date' and verbose: # Don't print a message for the "date" entry |
| 569 | stderr.write('Got unknown metal "{}" with value "{}"\n'.format(metal,price)) |
| 570 | metalstr = '\n'.join(metallist) |
| 571 | |
| 572 | if validfloat(bitcoin['usd']): |
| 573 | bitcoinstr = '{:{}}{} US$ # From services.packetizer.com/btc\n'.format( |
| 574 | 'bitcoin',maxlen,bitcoin['usd']) |
| 575 | else: |
| 576 | stderr.write('Got invalid bitcoin rate "{}"\n', bitcoint['usd']) |
| 577 | bitcointstr='' |
| 578 | |
| 579 | ####################################################### |
| 580 | # |
| 581 | # Get CPI data |
| 582 | # |
| 583 | |
| 584 | docpi=False |
| 585 | if cpi_file=='-': |
| 586 | docpi=True |
| 587 | else: |
| 588 | try: |
| 589 | filedate = getmtime(cpi_file) |
| 590 | filedate = date.fromtimestamp(filedate) |
| 591 | today = date.today() |
| 592 | dmonth = (today.month-filedate.month) + 12*(today.year-filedate.year) |
| 593 | if dmonth>1 or (dmonth==1 and today.day>18): |
| 594 | docpi=True |
| 595 | except FileNotFoundError: |
| 596 | docpi=True |
| 597 | |
| 598 | if docpi: |
| 599 | headers = {'Content-type': 'application/json'} |
| 600 | yearlist = list(range(date.today().year,1912,-10)) |
| 601 | if yearlist[-1]>1912: |
| 602 | yearlist.append(1912) |
| 603 | cpi=[] |
| 604 | lastcpi = 0 |
| 605 | query = {"seriesid": ['CUUR0000SA0']} |
| 606 | if cpikey: |
| 607 | query["registrationkey"]=cpikey |
| 608 | ######################################################################## |
| 609 | # The api.bls.gov site currently (2024-02-15) resolves to an |
| 610 | # IPv4 address which works, and an IPv6 address which does |
| 611 | # not. The urllib3 package does not currently implement the |
| 612 | # Happy Eyeballs algorithm, nor any other mechanism to re-try |
| 613 | # hung connections with alternative addresses returned by DNS. |
| 614 | # In the interest of expediency, we temporarily force the |
| 615 | # connection to api.bls.gov to only use IPv4; hopefully at |
| 616 | # some future date either urllib3 will gain the necessary |
| 617 | # features and/or the BLS will fix their configuration so |
| 618 | # that all A and AAAA records for api.bls.gov resolve to an |
| 619 | # operational server. At that time we can remove the three |
| 620 | # references to "requests.packages.urllib3.util.connection.HAS_IPV6" |
| 621 | # in this function. |
| 622 | ######################################################################## |
| 623 | save_rpuucH = requests.packages.urllib3.util.connection.HAS_IPV6 |
| 624 | requests.packages.urllib3.util.connection.HAS_IPV6 = False |
| 625 | for endyear in range(len(yearlist)-1): |
| 626 | query["startyear"]=str(yearlist[endyear+1]+1) |
| 627 | query["endyear"]=str(yearlist[endyear]) |
| 628 | data = json.dumps(query) |
| 629 | p = requests.post('https://api.bls.gov/publicAPI/v2/timeseries/data/', data=data, headers=headers) |
| 630 | json_data = json.loads(p.text) |
| 631 | if json_data['status']=="REQUEST_NOT_PROCESSED": |
| 632 | docpi=False |
| 633 | stderr.write("Unable to update CPI data: Exceeded daily threshold for BLS requests\n") |
| 634 | break |
| 635 | for series in json_data['Results']['series']: |
| 636 | for item in series['data']: |
| 637 | if not ('M01' <= item['period'] <= 'M12'): |
| 638 | continue |
| 639 | year = int(item['year']) + int(item['period'][1:])/12 |
| 640 | value = item['value'] |
| 641 | cpi.append(' '*10 + "{} {} \\".format(year,value)) |
| 642 | if lastcpi==0: |
| 643 | lastcpi=value |
| 644 | lastyear=year |
| 645 | firstcpi=value |
| 646 | firstyear=year |
| 647 | requests.packages.urllib3.util.connection.HAS_IPV6 = save_rpuucH |
| 648 | if docpi: # Check again because request may have failed |
| 649 | cpi.reverse() |
| 650 | cpistr = '\n'.join(cpi) |
| 651 | |
| 652 | cpi_text = ("""!message Consumer price index data from US BLS, {datestr} |
| 653 | |
| 654 | UScpi[1] noerror \\ |
| 655 | {cpistr} |
| 656 | |
| 657 | UScpi_now {lastcpi} |
| 658 | UScpi_lastdate {lastyear} |
| 659 | |
| 660 | USdollars_in(date) units=[1;$] domain=[{firstyear},{lastyear}] \\ |
| 661 | range=[1,{maxinfl}] \\ |
| 662 | US$ UScpi_now / UScpi(date) ;\\ |
| 663 | ~UScpi(US$ UScpi_now / USdollars_in) |
| 664 | USinflation_since(date) units=[1;1] domain=[{firstyear},{lastyear}] \\ |
| 665 | range=[1,{maxinfl}] \\ |
| 666 | UScpi_now / UScpi(date) ;\\ |
| 667 | ~UScpi(UScpi_now / USinflation_since) |
| 668 | |
| 669 | """.format(datestr=datestr,cpistr=cpistr,maxinfl=float(lastcpi)/float(firstcpi),lastcpi=lastcpi, |
| 670 | firstyear=firstyear,lastyear=lastyear) |
| 671 | ).replace('\n', linesep) |
| 672 | |
| 673 | ####################################################### |
| 674 | # |
| 675 | # Format output and write the currency file |
| 676 | # |
| 677 | |
| 678 | currency_text = ( |
| 679 | """# ISO Currency Codes |
| 680 | |
| 681 | {codestr} |
| 682 | |
| 683 | # Currency exchange rates source |
| 684 | |
| 685 | !message Currency exchange rates from {sourcename} on {datestr} |
| 686 | |
| 687 | {ratestr} |
| 688 | {bitcoinstr} |
| 689 | |
| 690 | # Precious metals prices from Packetizer (services.packetizer.com/spotprices) |
| 691 | |
| 692 | {metalstr} |
| 693 | |
| 694 | """.format(codestr=codestr, datestr=datestr, ratestr=ratestr, metalstr=metalstr, |
| 695 | bitcoinstr=bitcoinstr, sourcename=sourcename) |
| 696 | ).replace('\n', linesep) |
| 697 | |
| 698 | |
| 699 | ###################################### |
| 700 | |
| 701 | try: |
| 702 | if currency_file == '-': |
| 703 | codecs.StreamReader(stdout, codecs.getreader('utf8')).write(currency_text) |
| 704 | else: |
| 705 | with codecs.open(currency_file, 'w', 'utf8') as of: |
| 706 | of.write(currency_text) |
| 707 | except IOError as e: |
| 708 | stderr.write('Unable to write to output file:\n{}\n'.format(e)) |
| 709 | exit(1) |
| 710 | |
| 711 | if docpi: |
| 712 | try: |
| 713 | if cpi_file == '-': |
| 714 | codecs.StreamReader(stdout, codecs.getreader('utf8')).write(cpi_text) |
| 715 | else: |
| 716 | with codecs.open(cpi_file, 'w', 'utf8') as of: |
| 717 | of.write(cpi_text) |
| 718 | except IOError as e: |
| 719 | stderr.write('Unable to write to output file:\n{}\n'.format(e)) |
| 720 | exit(1) |
| 721 | |