From 52fa327a09a6f2f51798625fd347efb84309fd0e Mon Sep 17 00:00:00 2001 From: Anthony Manning-Franklin Date: Tue, 7 Feb 2017 11:30:15 +0800 Subject: [PATCH] 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. --- README.rst | 4 +-- docs/source/usage.rst | 20 ++++++++++-- forex_python/converter.py | 41 +++++++++++++++++++----- setup.py | 3 +- tests/test.py | 65 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index 8865978..90d195c 100644 --- a/README.rst +++ b/README.rst @@ -36,7 +36,7 @@ The rates are updated daily 3PM CET. BitCoin Price Source: --------------------- -Bitcoin prices calculated every minute. For more infomation visit [CoinDesk API](http://www.coindesk.com/api/). +Bitcoin prices calculated every minute. For more information visit [CoinDesk API](http://www.coindesk.com/api/). Installation -------------- @@ -124,4 +124,4 @@ We welcome your feedback and support, raise `github ticket`_ if you want to repo .. _contact us here: https://micropyramid.com/contact-us/ .. _Forex Python: https://micropyramid.com/oss/ .. _github ticket: https://github.com/MicroPyramid/forex-python/issues -.. _Documentation Here: http://forex-python.readthedocs.org/en/latest/?badge=latest \ No newline at end of file +.. _Documentation Here: http://forex-python.readthedocs.org/en/latest/?badge=latest diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 88d3b2d..6322d16 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -35,6 +35,23 @@ Currency Rates >>> c.convert('USD', 'INR', 10, date_obj) 585.09 +7. Force use of Decimal:: + >>> from forex_python.converter import CurrencyRates + >>> c = CurrencyRates(force_decimal=True) + >>> c.convert('USD', 'INR', Decimal('10.45')) + 705.09 + >>> c.convert('USD', 'INR', 10) + DecimalFloatMismatchError: convert requires amount parameter is of type Decimal when use_decimal=True + +8. Detect use of Decimal:: + >>> from forex_python.converter import CurrencyRates + >>> c = CurrencyRates() + >>> c.convert('USD', 'INR', Decimal('10.45')) + 705.09 + >>> c.convert('USD', 'INR', 10) + 674.73 + + Bitcoin Prices: --------------- 1. Get latest price of one Bitcoin:: @@ -98,6 +115,3 @@ Currency Symbols & Codes u'European Euro' >>> c.get_currency_name('INR') u'Indian rupee' - - - diff --git a/forex_python/converter.py b/forex_python/converter.py index 36a57fe..fff0102 100644 --- a/forex_python/converter.py +++ b/forex_python/converter.py @@ -1,6 +1,7 @@ import os -import json +import simplejson as json import requests +from decimal import Decimal class RatesNotAvailableError(Exception): @@ -10,10 +11,17 @@ class RatesNotAvailableError(Exception): pass +class DecimalFloatMismatchError(Exception): + """ + A float has been supplied when force_decimal was set to True + """ + pass + + class Common: - def __init__(self): - pass + def __init__(self, force_decimal=False): + self._force_decimal = force_decimal def _source_url(self): return "http://api.fixer.io/" @@ -24,6 +32,16 @@ def _get_date_string(self, date_obj): date_str = date_obj.strftime('%Y-%m-%d') return date_str + def _decode_rates(self, response, use_decimal=False): + if self._force_decimal or use_decimal: + decoded_data = json.loads(response.text, use_decimal=True).get('rates', {}) + else: + decoded_data = response.json().get('rates', {}) + return decoded_data + + def _get_decoded_rate(self, response, dest_cur, use_decimal=False): + return self._decode_rates(response, use_decimal=use_decimal).get(dest_cur, None) + class CurrencyRates(Common): @@ -33,7 +51,7 @@ def get_rates(self, base_cur, date_obj=None): source_url = self._source_url() + date_str response = requests.get(source_url, params=payload) if response.status_code == 200: - rates = response.json().get('rates', {}) + rates = self._decode_rates(response) return rates raise RatesNotAvailableError("Currency Rates Source Not Ready") @@ -43,7 +61,7 @@ def get_rate(self, base_cur, dest_cur, date_obj=None): source_url = self._source_url() + date_str response = requests.get(source_url, params=payload) if response.status_code == 200: - rate = response.json().get('rates', {}).get(dest_cur) + rate = self._get_decoded_rate(response, dest_cur) if not rate: raise RatesNotAvailableError("Currency Rate {0} => {1} not available for Date {2}".format( base_cur, dest_cur, date_str)) @@ -51,17 +69,24 @@ def get_rate(self, base_cur, dest_cur, date_obj=None): raise RatesNotAvailableError("Currency Rates Source Not Ready") def convert(self, base_cur, dest_cur, amount, date_obj=None): + if isinstance(amount, Decimal): + use_decimal = True + else: + use_decimal = self._force_decimal date_str = self._get_date_string(date_obj) payload = {'base': base_cur, 'symbols': dest_cur} source_url = self._source_url() + date_str response = requests.get(source_url, params=payload) if response.status_code == 200: - rate = response.json().get('rates', {}).get(dest_cur, None) + rate = self._get_decoded_rate(response, dest_cur, use_decimal=use_decimal) if not rate: raise RatesNotAvailableError("Currency {0} => {1} rate not available for Date {2}.".format( source_url, dest_cur, date_str)) - converted_amount = rate * amount - return converted_amount + try: + converted_amount = rate * amount + return converted_amount + except TypeError: + raise DecimalFloatMismatchError("convert requires amount parameter is of type Decimal when force_decimal=True") raise RatesNotAvailableError("Currency Rates Source Not Ready") diff --git a/setup.py b/setup.py index b99019a..9c14b15 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os from setuptools import setup, find_packages -VERSION = '0.3.1' +VERSION = '0.3.2' setup( name='forex-python', @@ -16,6 +16,7 @@ include_package_data=True, install_requires=[ 'requests', + 'simplejson', ], classifiers=[ 'Intended Audience :: Developers', diff --git a/tests/test.py b/tests/test.py index c99e8e9..f6fd0a4 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,6 +1,9 @@ import datetime from unittest import TestCase -from forex_python.converter import get_rates, get_rate, convert, get_symbol, get_currency_name, RatesNotAvailableError +from forex_python.converter import (get_rates, get_rate, convert, get_symbol, + get_currency_name, RatesNotAvailableError, + CurrencyRates, DecimalFloatMismatchError) +from decimal import Decimal class TestGetRates(TestCase): @@ -83,6 +86,66 @@ def test_amount_convert_invalid_currency(self): self.assertRaises(RatesNotAvailableError, convert, 'ABC', 'XYZ', 10) +class TestForceDecimalAmountConvert(TestCase): + """ + Test the force_decimal=True type enforcing + """ + + def setUp(self): + self.c = CurrencyRates(force_decimal=True) + + def test_amount_decimal_convert(self): + amount = self.c.convert('USD', 'INR', Decimal('10.45')) + + self.assertTrue(isinstance(amount, Decimal)) + + def test_amount_decimal_convert_date(self): + date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date() + amount = self.c.convert('USD', 'INR', Decimal('10.45'), date_obj) + + self.assertTrue(isinstance(amount, Decimal)) + + def test_amount_decimal_invalid_type(self): + self.assertRaises(DecimalFloatMismatchError, self.c.convert, 'USD', 'INR', 10.45) + + def test_decimal_get_rates_valid_code(self): + all_rates = self.c.get_rates('USD') + # Check if return value of get_rates dictionary + self.assertTrue(isinstance(all_rates, dict)) + # Test at least one rate value returned + self.assertTrue(len(all_rates.keys())) + # Test one rate in returned dict is now a Decimal + self.assertTrue(isinstance(all_rates.get('INR'), Decimal)) + + def test_decimal_get_rates_with_date(self): + date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date() + all_rates = self.c.get_rates('USD', date_obj) + # Check if return value of get_rates dictionary + self.assertTrue(isinstance(all_rates, dict)) + # Test at least one rate value returned + self.assertTrue(len(all_rates.keys())) + # Test one rate in returned dict is now a Decimal + self.assertTrue(isinstance(all_rates.get('INR'), Decimal)) + + def test_decimal_get_rates_invalid_code(self): + self.assertRaises(RatesNotAvailableError, self.c.get_rates, 'XYZ') + + def test_decimal_get_rate_with_valid_codes(self): + rate = self.c.get_rate('USD', 'INR') + # check if return value is Decimal + self.assertTrue(isinstance(rate, Decimal)) + + def test_decimal_get_rate_with_date(self): + date_obj = datetime.datetime.strptime('2010-05-10', "%Y-%m-%d").date() + rate = self.c.get_rate('USD', 'INR', date_obj) + # check if return value is Decimal + self.assertTrue(isinstance(rate, Decimal)) + + def test_decimal_get_rate_with_invalid_codes(self): + # raise exception for invalid currency codes + self.assertRaises(RatesNotAvailableError, self.c.get_rate, 'ABCD', 'XYZ') + + class TestCurrencySymbol(TestCase): """ test currency symbols from currency codes