Skip to content

Commit 52fa327

Browse files
author
Anthony Manning-Franklin
committed
Added support for Decimal types and the ability to force use of Decimal. Use of Decimal instead of float in monetary arithmetic is recommended best practice.
1 parent 650f9b6 commit 52fa327

5 files changed

Lines changed: 118 additions & 15 deletions

File tree

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The rates are updated daily 3PM CET.
3636

3737
BitCoin Price Source:
3838
---------------------
39-
Bitcoin prices calculated every minute. For more infomation visit [CoinDesk API](http://www.coindesk.com/api/).
39+
Bitcoin prices calculated every minute. For more information visit [CoinDesk API](http://www.coindesk.com/api/).
4040

4141
Installation
4242
--------------
@@ -124,4 +124,4 @@ We welcome your feedback and support, raise `github ticket`_ if you want to repo
124124
.. _contact us here: https://micropyramid.com/contact-us/
125125
.. _Forex Python: https://micropyramid.com/oss/
126126
.. _github ticket: https://github.com/MicroPyramid/forex-python/issues
127-
.. _Documentation Here: http://forex-python.readthedocs.org/en/latest/?badge=latest
127+
.. _Documentation Here: http://forex-python.readthedocs.org/en/latest/?badge=latest

docs/source/usage.rst

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ Currency Rates
3535
>>> c.convert('USD', 'INR', 10, date_obj)
3636
585.09
3737

38+
7. Force use of Decimal::
39+
>>> from forex_python.converter import CurrencyRates
40+
>>> c = CurrencyRates(force_decimal=True)
41+
>>> c.convert('USD', 'INR', Decimal('10.45'))
42+
705.09
43+
>>> c.convert('USD', 'INR', 10)
44+
DecimalFloatMismatchError: convert requires amount parameter is of type Decimal when use_decimal=True
45+
46+
8. Detect use of Decimal::
47+
>>> from forex_python.converter import CurrencyRates
48+
>>> c = CurrencyRates()
49+
>>> c.convert('USD', 'INR', Decimal('10.45'))
50+
705.09
51+
>>> c.convert('USD', 'INR', 10)
52+
674.73
53+
54+
3855
Bitcoin Prices:
3956
---------------
4057
1. Get latest price of one Bitcoin::
@@ -98,6 +115,3 @@ Currency Symbols & Codes
98115
u'European Euro'
99116
>>> c.get_currency_name('INR')
100117
u'Indian rupee'
101-
102-
103-

forex_python/converter.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
2-
import json
2+
import simplejson as json
33
import requests
4+
from decimal import Decimal
45

56

67
class RatesNotAvailableError(Exception):
@@ -10,10 +11,17 @@ class RatesNotAvailableError(Exception):
1011
pass
1112

1213

14+
class DecimalFloatMismatchError(Exception):
15+
"""
16+
A float has been supplied when force_decimal was set to True
17+
"""
18+
pass
19+
20+
1321
class Common:
1422

15-
def __init__(self):
16-
pass
23+
def __init__(self, force_decimal=False):
24+
self._force_decimal = force_decimal
1725

1826
def _source_url(self):
1927
return "http://api.fixer.io/"
@@ -24,6 +32,16 @@ def _get_date_string(self, date_obj):
2432
date_str = date_obj.strftime('%Y-%m-%d')
2533
return date_str
2634

35+
def _decode_rates(self, response, use_decimal=False):
36+
if self._force_decimal or use_decimal:
37+
decoded_data = json.loads(response.text, use_decimal=True).get('rates', {})
38+
else:
39+
decoded_data = response.json().get('rates', {})
40+
return decoded_data
41+
42+
def _get_decoded_rate(self, response, dest_cur, use_decimal=False):
43+
return self._decode_rates(response, use_decimal=use_decimal).get(dest_cur, None)
44+
2745

2846
class CurrencyRates(Common):
2947

@@ -33,7 +51,7 @@ def get_rates(self, base_cur, date_obj=None):
3351
source_url = self._source_url() + date_str
3452
response = requests.get(source_url, params=payload)
3553
if response.status_code == 200:
36-
rates = response.json().get('rates', {})
54+
rates = self._decode_rates(response)
3755
return rates
3856
raise RatesNotAvailableError("Currency Rates Source Not Ready")
3957

@@ -43,25 +61,32 @@ def get_rate(self, base_cur, dest_cur, date_obj=None):
4361
source_url = self._source_url() + date_str
4462
response = requests.get(source_url, params=payload)
4563
if response.status_code == 200:
46-
rate = response.json().get('rates', {}).get(dest_cur)
64+
rate = self._get_decoded_rate(response, dest_cur)
4765
if not rate:
4866
raise RatesNotAvailableError("Currency Rate {0} => {1} not available for Date {2}".format(
4967
base_cur, dest_cur, date_str))
5068
return rate
5169
raise RatesNotAvailableError("Currency Rates Source Not Ready")
5270

5371
def convert(self, base_cur, dest_cur, amount, date_obj=None):
72+
if isinstance(amount, Decimal):
73+
use_decimal = True
74+
else:
75+
use_decimal = self._force_decimal
5476
date_str = self._get_date_string(date_obj)
5577
payload = {'base': base_cur, 'symbols': dest_cur}
5678
source_url = self._source_url() + date_str
5779
response = requests.get(source_url, params=payload)
5880
if response.status_code == 200:
59-
rate = response.json().get('rates', {}).get(dest_cur, None)
81+
rate = self._get_decoded_rate(response, dest_cur, use_decimal=use_decimal)
6082
if not rate:
6183
raise RatesNotAvailableError("Currency {0} => {1} rate not available for Date {2}.".format(
6284
source_url, dest_cur, date_str))
63-
converted_amount = rate * amount
64-
return converted_amount
85+
try:
86+
converted_amount = rate * amount
87+
return converted_amount
88+
except TypeError:
89+
raise DecimalFloatMismatchError("convert requires amount parameter is of type Decimal when force_decimal=True")
6590
raise RatesNotAvailableError("Currency Rates Source Not Ready")
6691

6792

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
from setuptools import setup, find_packages
44

5-
VERSION = '0.3.1'
5+
VERSION = '0.3.2'
66

77
setup(
88
name='forex-python',
@@ -16,6 +16,7 @@
1616
include_package_data=True,
1717
install_requires=[
1818
'requests',
19+
'simplejson',
1920
],
2021
classifiers=[
2122
'Intended Audience :: Developers',

tests/test.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import datetime
22
from unittest import TestCase
3-
from forex_python.converter import get_rates, get_rate, convert, get_symbol, get_currency_name, RatesNotAvailableError
3+
from forex_python.converter import (get_rates, get_rate, convert, get_symbol,
4+
get_currency_name, RatesNotAvailableError,
5+
CurrencyRates, DecimalFloatMismatchError)
6+
from decimal import Decimal
47

58

69
class TestGetRates(TestCase):
@@ -83,6 +86,66 @@ def test_amount_convert_invalid_currency(self):
8386
self.assertRaises(RatesNotAvailableError, convert, 'ABC', 'XYZ', 10)
8487

8588

89+
class TestForceDecimalAmountConvert(TestCase):
90+
"""
91+
Test the force_decimal=True type enforcing
92+
"""
93+
94+
def setUp(self):
95+
self.c = CurrencyRates(force_decimal=True)
96+
97+
def test_amount_decimal_convert(self):
98+
amount = self.c.convert('USD', 'INR', Decimal('10.45'))
99+
100+
self.assertTrue(isinstance(amount, Decimal))
101+
102+
def test_amount_decimal_convert_date(self):
103+
date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date()
104+
amount = self.c.convert('USD', 'INR', Decimal('10.45'), date_obj)
105+
106+
self.assertTrue(isinstance(amount, Decimal))
107+
108+
def test_amount_decimal_invalid_type(self):
109+
self.assertRaises(DecimalFloatMismatchError, self.c.convert, 'USD', 'INR', 10.45)
110+
111+
def test_decimal_get_rates_valid_code(self):
112+
all_rates = self.c.get_rates('USD')
113+
# Check if return value of get_rates dictionary
114+
self.assertTrue(isinstance(all_rates, dict))
115+
# Test at least one rate value returned
116+
self.assertTrue(len(all_rates.keys()))
117+
# Test one rate in returned dict is now a Decimal
118+
self.assertTrue(isinstance(all_rates.get('INR'), Decimal))
119+
120+
def test_decimal_get_rates_with_date(self):
121+
date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date()
122+
all_rates = self.c.get_rates('USD', date_obj)
123+
# Check if return value of get_rates dictionary
124+
self.assertTrue(isinstance(all_rates, dict))
125+
# Test at least one rate value returned
126+
self.assertTrue(len(all_rates.keys()))
127+
# Test one rate in returned dict is now a Decimal
128+
self.assertTrue(isinstance(all_rates.get('INR'), Decimal))
129+
130+
def test_decimal_get_rates_invalid_code(self):
131+
self.assertRaises(RatesNotAvailableError, self.c.get_rates, 'XYZ')
132+
133+
def test_decimal_get_rate_with_valid_codes(self):
134+
rate = self.c.get_rate('USD', 'INR')
135+
# check if return value is Decimal
136+
self.assertTrue(isinstance(rate, Decimal))
137+
138+
def test_decimal_get_rate_with_date(self):
139+
date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date()
140+
rate = self.c.get_rate('USD', 'INR', date_obj)
141+
# check if return value is Decimal
142+
self.assertTrue(isinstance(rate, Decimal))
143+
144+
def test_decimal_get_rate_with_invalid_codes(self):
145+
# raise exception for invalid currency codes
146+
self.assertRaises(RatesNotAvailableError, self.c.get_rate, 'ABCD', 'XYZ')
147+
148+
86149
class TestCurrencySymbol(TestCase):
87150
"""
88151
test currency symbols from currency codes

0 commit comments

Comments
 (0)