changeset d629bd2c94a0 in modules/currency:6.2
details: https://hg.tryton.org/modules/currency?cmd=changeset&node=d629bd2c94a0
description:
        Replace forex-python and fetch rates directly from EU website

        https://theforexapi.com/ is frequently down which breaks the tests. The 
EU
        website is more reliable.
        As forex-python is just also used to set the symbol of currencies when
        importing, we can just fill the symbol when it is empty with the 
alpha-3 code.

        issue11731
        review439631003
        (grafted from 2097ae9311186817cd70c172c478d102435ad2bf)
diffstat:

 currency.py                  |  12 +----
 ecb.py                       |  98 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/import_currencies.py |   7 +-
 setup.py                     |   6 +-
 tests/test_currency.py       |  73 ++++++++++++++++++++++++++++++++
 5 files changed, 179 insertions(+), 17 deletions(-)

diffs (295 lines):

diff -r fdee82f95ba7 -r d629bd2c94a0 currency.py
--- a/currency.py       Mon Nov 01 17:02:12 2021 +0100
+++ b/currency.py       Sat Oct 22 01:02:01 2022 +0200
@@ -10,12 +10,6 @@
 from sql import Window
 from sql.functions import NthValue
 
-try:
-    from forex_python.converter import CurrencyRates, RatesNotAvailableError
-    get_rates = CurrencyRates(force_decimal=True).get_rates
-except ImportError:
-    CurrencyRates = get_rates = None
-
 from trytond.i18n import gettext
 from trytond.model import (
     ModelView, ModelSQL, DeactivableMixin, fields, Unique, Check, SymbolMixin,
@@ -25,6 +19,7 @@
 from trytond.rpc import RPC
 from trytond.pyson import Eval, If
 
+from .ecb import RatesNotAvailableError, get_rates
 from .exceptions import RateError
 from .ir import rate_decimal
 
@@ -301,7 +296,8 @@
     __name__ = 'currency.cron'
 
     source = fields.Selection(
-        [], "Source", required=True,
+        [('ecb', "European Central Bank")],
+        "Source", required=True,
         help="The external source for rates.")
     frequency = fields.Selection([
             ('daily', "Daily"),
@@ -341,8 +337,6 @@
         cls._buttons.update({
                 'run': {},
                 })
-        if CurrencyRates:
-            cls.source.selection.append(('ecb', "European Central Bank"))
 
     @classmethod
     def default_frequency(cls):
diff -r fdee82f95ba7 -r d629bd2c94a0 ecb.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/ecb.py    Sat Oct 22 01:02:01 2022 +0200
@@ -0,0 +1,98 @@
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+import datetime as dt
+import ssl
+import sys
+
+try:
+    from lxml import etree as ET
+except ImportError:
+    try:
+        import xml.etree.cElementTree as ET
+    except ImportError:
+        import xml.etree.ElementTree as ET
+
+from decimal import Decimal
+from urllib.error import HTTPError
+from urllib.request import urlopen
+
+_URL = 'https://www.ecb.europa.eu/stats/eurofxref/'
+_URL_TODAY = _URL + 'eurofxref-daily.xml'
+_URL_90 = _URL + 'eurofxref-hist-90d.xml'
+_URL_HIST = _URL + 'eurofxref-hist.xml'
+_START_DATE = dt.date(1999, 1, 4)
+_CUBE_TAG = '{http://www.ecb.int/vocabulary/2002-08-01/eurofxref}Cube'
+
+
+class RatesNotAvailableError(Exception):
+    pass
+
+
+class UnsupportedCurrencyError(Exception):
+    pass
+
+
+def _parse_time(time):
+    return dt.datetime.strptime(time, '%Y-%m-%d').date()
+
+
+def _find_time(source, time=None):
+    for _, element in ET.iterparse(source):
+        if element.tag == _CUBE_TAG and 'time' in element.attrib:
+            if time and _parse_time(element.attrib.get('time')) <= time:
+                return element
+            elif time is None:
+                return element
+            element.clear()
+
+
+def get_rates(currency='EUR', date=None):
+    if date is None:
+        date = dt.date.today()
+    if date < _START_DATE:
+        date = _START_DATE
+    context = ssl.create_default_context()
+    try:
+        with urlopen(_URL_TODAY, context=context) as response:
+            element = _find_time(response)
+            last_date = _parse_time(element.attrib['time'])
+            if last_date < date:
+                raise RatesNotAvailableError()
+            elif last_date == date:
+                return _compute_rates(element, currency, date)
+
+        if last_date - date < dt.timedelta(days=90):
+            url = _URL_90
+        else:
+            url = _URL_HIST
+        with urlopen(url, context=context) as response:
+            element = _find_time(response, date)
+            return _compute_rates(element, currency, date)
+    except HTTPError as e:
+        raise RatesNotAvailableError() from e
+
+
+def _compute_rates(element, currency, date):
+    currencies = {}
+    for cur in element:
+        currencies[cur.attrib['currency']] = Decimal(cur.attrib['rate'])
+    if currency != 'EUR':
+        currencies['EUR'] = Decimal(1)
+        try:
+            base_rate = currencies.pop(currency)
+        except KeyError:
+            raise UnsupportedCurrencyError(f'{currency} is not available')
+        for cur, rate in currencies.items():
+            currencies[cur] = (rate / base_rate).quantize(Decimal('.0001'))
+    return currencies
+
+
+if __name__ == '__main__':
+    currency = 'EUR'
+    if len(sys.argv) > 1:
+        currency = sys.argv[1]
+    date = None
+    if len(sys.argv) > 2:
+        date = dt.datetime.strptime(sys.argv[2], '%Y-%m-%d').date()
+    print(get_rates(currency, date))
diff -r fdee82f95ba7 -r d629bd2c94a0 scripts/import_currencies.py
--- a/scripts/import_currencies.py      Mon Nov 01 17:02:12 2021 +0100
+++ b/scripts/import_currencies.py      Sat Oct 22 01:02:01 2022 +0200
@@ -7,7 +7,6 @@
 from argparse import ArgumentParser
 
 import pycountry
-from forex_python.converter import CurrencyCodes
 
 try:
     from progressbar import ProgressBar, Bar, ETA, SimpleProgress
@@ -50,7 +49,6 @@
 def update_currencies(currencies):
     print("Update currencies", file=sys.stderr)
     Currency = Model.get('currency.currency')
-    codes = CurrencyCodes()
 
     records = []
     for currency in _progress(pycountry.currencies):
@@ -58,10 +56,11 @@
         if code in currencies:
             record = currencies[code]
         else:
-            record = Currency(code=code)
+            record = Currency(code=code, symbol=None)
         record.name = _remove_forbidden_chars(currency.name)
         record.numeric_code = currency.numeric
-        record.symbol = codes.get_symbol(currency.alpha_3) or currency.alpha_3
+        if not record.symbol:
+            record.symbol = currency.alpha_3
         records.append(record)
 
     Currency.save(records)
diff -r fdee82f95ba7 -r d629bd2c94a0 setup.py
--- a/setup.py  Mon Nov 01 17:02:12 2021 +0100
+++ b/setup.py  Sat Oct 22 01:02:01 2022 +0200
@@ -66,7 +66,7 @@
         requires.append(get_require_version('trytond_%s' % dep))
 requires.append(get_require_version('trytond'))
 
-tests_require = [get_require_version('proteus'), 'pycountry', 'forex-python']
+tests_require = [get_require_version('proteus'), 'pycountry']
 dependency_links = []
 if minor_version % 2:
     dependency_links.append(
@@ -143,9 +143,7 @@
     python_requires='>=3.6',
     install_requires=requires,
     extras_require={
-        'data': [
-            'pycountry', 'forex-python', get_require_version('proteus')],
-        'ecb_rate': ['forex-python'],
+        'data': ['pycountry', get_require_version('proteus')],
         },
     dependency_links=dependency_links,
     zip_safe=False,
diff -r fdee82f95ba7 -r d629bd2c94a0 tests/test_currency.py
--- a/tests/test_currency.py    Mon Nov 01 17:02:12 2021 +0100
+++ b/tests/test_currency.py    Sat Oct 22 01:02:01 2022 +0200
@@ -5,6 +5,8 @@
 import unittest
 from decimal import Decimal
 import trytond.tests.test_tryton
+from trytond.modules.currency.ecb import (
+    RatesNotAvailableError, UnsupportedCurrencyError, get_rates)
 from trytond.transaction import Transaction
 from trytond.tests.test_tryton import ModuleTestCase, with_transaction
 from trytond.tests.test_tryton import doctest_teardown
@@ -368,10 +370,81 @@
         self.assertSetEqual(data, result)
 
 
+class ECBtestCase(unittest.TestCase):
+
+    def test_rate_EUR_week_ago(self):
+        "Fetch EUR rates a week ago"
+        week_ago = datetime.date.today() - datetime.timedelta(days=7)
+
+        rates = get_rates('EUR', week_ago)
+
+        self.assertNotIn('EUR', rates)
+        self.assertIn('USD', rates)
+
+    def test_rate_USD_week_ago(self):
+        "Fetch USD rates a week ago"
+        week_ago = datetime.date.today() - datetime.timedelta(days=7)
+
+        rates = get_rates('USD', week_ago)
+
+        self.assertIn('EUR', rates)
+        self.assertNotIn('USD', rates)
+
+    def test_rate_EUR_on_weekend(self):
+        "Fetch EUR rates on weekend"
+
+        rates_fr = get_rates('EUR', datetime.date(2022, 9, 30))
+        rates_sa = get_rates('EUR', datetime.date(2022, 10, 2))
+        rates_su = get_rates('EUR', datetime.date(2022, 10, 2))
+
+        self.assertEqual(rates_sa, rates_fr)
+        self.assertEqual(rates_su, rates_fr)
+
+    def test_rate_USD_on_weekend(self):
+        "Fetch USD rates on weekend"
+
+        rates_fr = get_rates('USD', datetime.date(2022, 9, 30))
+        rates_sa = get_rates('USD', datetime.date(2022, 10, 2))
+        rates_su = get_rates('USD', datetime.date(2022, 10, 2))
+
+        self.assertEqual(rates_sa, rates_fr)
+        self.assertEqual(rates_su, rates_fr)
+
+    def test_rate_EUR_start_date(self):
+        "Fetch EUR rates at start date"
+
+        rates = get_rates('EUR', datetime.date(1999, 1, 4))
+
+        self.assertEqual(rates['USD'], Decimal('1.1789'))
+
+    def test_rate_USD_start_date(self):
+        "Fetch USD rates at start date"
+
+        rates = get_rates('USD', datetime.date(1999, 1, 4))
+
+        self.assertEqual(rates['EUR'], Decimal('0.8482'))
+
+    def test_rate_in_future(self):
+        "Fetch rate in future raise an error"
+        future = datetime.date.today() + datetime.timedelta(days=2)
+
+        with self.assertRaises(RatesNotAvailableError):
+            get_rates('USD', future)
+
+    def test_rate_unsupported_currency(self):
+        "Fetch rate for unsupported currency"
+        date = datetime.date.today() - datetime.timedelta(days=2)
+
+        with self.assertRaises(UnsupportedCurrencyError):
+            get_rates('XXX', date)
+
+
 def suite():
     suite = trytond.tests.test_tryton.suite()
     suite.addTests(unittest.TestLoader().loadTestsFromTestCase(
             CurrencyTestCase))
+    suite.addTests(unittest.TestLoader().loadTestsFromTestCase(
+            ECBtestCase))
     suite.addTests(doctest.DocFileSuite(
             'scenario_currency_compute.rst',
             tearDown=doctest_teardown, encoding='utf-8',

Reply via email to