Commits

Virgil Dupras committed a428af3

Added CurrencyProviderPlugin

This allows for custom currencies. It even allows for custom providers for
exchange rates. [#33 state:fixed]

Comments (0)

Files changed (12)

 62a55da0cd9750c2f67e6e95a1516649ae8a8ec5 ambuttonbar
 f46c13c74495685c4899a89e17fa29f141bb252f cocoalib
-df78b9c963455162b690bbbc6f3dab5d8050e72d hscommon
+6816bbd8de2c1332a5b5b60cd6ef0533aaa063b0 hscommon
 bbc3cc9d5986f4a9b96e400298a2dbd0ff614173 psmtabbarcontrol
 28251d25784fb948a9a6ee7a67ae9477416e11c1 qtlib
 import re
 import importlib
 
-from hscommon.currency import USD
+from hscommon.currency import Currency, USD
 from hscommon.notify import Broadcaster
 from hscommon import io
 from hscommon.util import nonone
 from .model import currency
 from .model.amount import parse_amount, format_amount
 from .model.date import parse_date, format_date
-from .plugin import Plugin
+from .plugin import Plugin, CurrencyProviderPlugin
 
 class PreferenceNames:
     HadFirstLaunch = 'HadFirstLaunch'
         self.saved_custom_ranges = [None] * 3
         self._load_custom_ranges()
         self._load_plugins(plugin_model_path)
+        self._hook_currency_plugins()
         self._update_autosave_timer()
     
     #--- Private
                     pass
         del sys.path[0]
     
+    def _hook_currency_plugins(self):
+        currency_plugins = [p for p in self.plugins if issubclass(p, CurrencyProviderPlugin)]
+        for p in currency_plugins:
+            Currency.get_rates_db().register_rate_provider(p().wrapped_get_currency_rates)
+    
     def _save_custom_ranges(self):
         custom_ranges = []
         for custom_range in self.saved_custom_ranges:

core/gui/empty_view.py

     
     def __init__(self, mainwindow):
         BaseView.__init__(self, mainwindow)
-        plugin_names = [p.NAME for p in self.mainwindow.app.plugins]
+        plugin_names = [p.NAME for p in self.mainwindow.app.plugins if p.IS_VIEW]
         self.plugin_list = GUISelectableList(plugin_names)
     
     #--- Public

core/model/currency.py

 import xmlrpc.client
 from datetime import date, datetime
 
-from hscommon.currency import Currency, RatesDB
+from hscommon.currency import Currency, RatesDB, BUILTIN_CURRENCY_CODES, CurrencyNotSupportedException
 
 CURRENCY_SERVER = 'http://currency.hardcoded.net/'
 
     return date(t.tm_year, t.tm_mon, t.tm_mday)
 
 def default_currency_rate_provider(currency, start_date, end_date):
+    if currency not in BUILTIN_CURRENCY_CODES:
+        raise CurrencyNotSupportedException()
     server = xmlrpc.client.ServerProxy(CURRENCY_SERVER)
     try:
         # dates passed to xmlrpclib *have* to be xmlrpclib.DateTime instances since 2.6
 # which should be included with this package. The terms are also available at 
 # http://www.hardcoded.net/licenses/bsd_license
 
+from datetime import date
+
 from hscommon.gui.column import Column
+from hscommon.currency import Currency, CurrencyNotSupportedException
 
 from .gui.base import BaseView
 from .gui.table import GUITable, Row
 
 class Plugin:
     NAME = ''
+    IS_VIEW = False
+
+class ViewPlugin:
+    IS_VIEW = True
     
     def __init__(self, mainwindow):
         self.mainwindow = mainwindow
         self.table.refresh_and_show_selection()
     
 
-class ReadOnlyTablePlugin(Plugin):
+class ReadOnlyTablePlugin(ViewPlugin):
     COLUMNS = []
     
     def __init__(self, mainwindow):
-        Plugin.__init__(self, mainwindow)
+        ViewPlugin.__init__(self, mainwindow)
         self.view = ReadOnlyTableView(self)
         self.table = self.view.table
     
     
     def fill_table(self):
         raise NotImplementedError()
-    
+    
+class CurrencyProviderPlugin(Plugin):
+    def __init__(self):
+        Plugin.__init__(self)
+        self.supported_currency_codes = set()
+        for code, name, exponent, fallback_rate in self.register_currencies():
+            Currency.register(code, name, exponent, latest_rate=fallback_rate)
+            self.supported_currency_codes.add(code)
+    
+    def wrapped_get_currency_rates(self, currency_code, start_date, end_date):
+        # Do not override
+        if currency_code not in self.supported_currency_codes:
+            raise CurrencyNotSupportedException()
+        try:
+            simple_result = self.get_currency_rate_today(currency_code)
+            if simple_result is not None:
+                return [(date.today(), simple_result)]
+            else:
+                return []
+        except NotImplementedError:
+            try:
+                return self.get_currency_rates(currency_code, start_date, end_date)
+            except NotImplementedError:
+                raise CurrencyNotSupportedException()
+    
+    def register_currencies(self):
+        """Override this and return a list of new currencies to support.
+        
+        The expected return value is a list of tuples (code, name, exponent, fallback_rate).
+        
+        exponent is the number of decimal numbers that should be displayed when formatting amounts
+        in this currency.
+        
+        fallback_rate is the rate to use in case we can't fetch a rate. You can use the rate that is
+        in effect when you write the plugin. Of course, it will become wildly innaccurate over time,
+        but it's still better than a rate of 1.
+        """
+        raise NotImplementedError()
+    
+    def get_currency_rate_today(self, currency_code):
+        """Override this if you have a 'simple' provider.
+        
+        If your provider doesn't give rates for any other date than today, overriding this method
+        instead of get_currency_rate() is the simplest choice.
+        
+        `currency_code` is a string representing the code of the currency to fetch, 'USD' for
+        example.
+        
+        Return a float representing the value of 1 unit of your currency in CAD.
+        
+        If you can't get a rate, return None.
+        
+        This method is called asynchronously, so it won't block moneyGuru if it takes time to
+        resolve.
+        """
+    
+    def get_currency_rates(self, currency_code, start_date, end_date):
+        """Override this if your provider gives rates for past dates.
+        
+        If your provider gives rates for past dates, it's better (although a bit more complicated)
+        to override this method so that moneyGuru can have more accurate rates.
+        
+        You must return a list of tuples (date, rate) with all rates you can fetch between
+        start_date and end_date. You don't need to have one item for every single date in the range
+        (for example, most of the time we don't have values during week-ends), moneyGuru correctly
+        handles holes in those values. Simply return whatever you can get.
+        
+        If you can't get a rate, return an empty list.
+        
+        This method is called asynchronously, so it won't block moneyGuru if it takes time to
+        resolve.
+        """
+        raise NotImplementedError()
+    

core/tests/currency_test.py

 import threading
 
 from hscommon.testutil import eq_, log_calls
-from hscommon.currency import Currency, USD, PLN, EUR, CAD, XPF
+from hscommon.currency import Currency, USD, EUR, CAD
 from hscommon import io
 
 from ..app import Application
 from .base import ApplicationGUI, TestApp, with_app, compare_apps
 from .model.currency_test import set_ratedb_for_tests
 
+PLN = Currency(code='PLN')
+XPF = Currency(code='XPF')
+
 def pytest_funcarg__fake_server(request):
     set_ratedb_for_tests()
 

core/tests/import_test.py

 from pytest import raises
 from hscommon.testutil import eq_
 from hscommon import io as hsio
-from hscommon.currency import PLN, CAD
+from hscommon.currency import Currency, CAD
 
 from .base import ApplicationGUI, TestApp, with_app, testdata
 from ..app import Application
 from ..loader.csv import CsvField
 from ..model.date import MonthRange, YearRange
 
+PLN = Currency(code='PLN')
+
 def importall(app, filename):
     app.doc.parse_file_for_import(filename)
     while app.iwin.panes:

core/tests/load_test.py

 from datetime import date
 
 from hscommon.testutil import eq_
-from hscommon.currency import PLN, CAD
+from hscommon.currency import Currency, CAD
 
 from ..document import ScheduleScope
 from ..model.account import AccountType
 from ..model.date import MonthRange
 from .base import compare_apps, TestApp, with_app, testdata
 
+PLN = Currency(code='PLN')
+
 #--- Pristine
 def test_dont_save_invalid_xml_characters(tmpdir):
     # It's possible that characters that are invalid in an XML file end up in a moneyGuru document

core/tests/loader/native_test.py

 
 from pytest import raises
 from hscommon.testutil import eq_
-from hscommon.currency import USD, PLN
+from hscommon.currency import Currency, USD
 
 from ..base import testdata
 from ...exception import FileFormatError
 from ...model.account import AccountType
 from ...model.amount import Amount
 
+PLN = Currency(code='PLN')
+
 def pytest_funcarg__loader(request):
     return native.Loader(USD)
     

core/tests/model/amount_test.py

 
 from pytest import raises
 from hscommon.testutil import eq_
-from hscommon.currency import CAD, EUR, PLN, USD, CZK, TND, JPY, BHD
+from hscommon.currency import Currency, CAD, EUR, USD
 
 from ...model.amount import format_amount, parse_amount, Amount
 
+PLN = Currency(code='PLN')
+CZK = Currency(code='CZK')
+TND = Currency(code='TND')
+JPY = Currency(code='JPY')
+BHD = Currency(code='BHD')
+
 #--- Amount
 def test_auto_quantize():
     # Amounts are automatically set to 2 digits after the dot.

core/tests/model/currency_test.py

     monkeypatch.setattr(xmlrpc.client, 'ServerProxy', FakeServer)
     FakeServer.ERROR_TO_RAISE = gaierror
     try:
-        default_currency_rate_provider(USD, date(2008, 5, 20), date(2008, 5, 20))
+        default_currency_rate_provider('USD', date(2008, 5, 20), date(2008, 5, 20))
     except gaierror:
         assert False
 
     monkeypatch.setattr(xmlrpc.client, 'ServerProxy', FakeServer)
     FakeServer.ERROR_TO_RAISE = error
     try:
-        default_currency_rate_provider(USD, date(2008, 5, 20), date(2008, 5, 20))
+        default_currency_rate_provider('USD', date(2008, 5, 20), date(2008, 5, 20))
     except error:
         assert False
 
     monkeypatch.setattr(xmlrpc.client, 'ServerProxy', FakeServer)
     FakeServer.ERROR_TO_RAISE = xmlrpc.client.Error
     try:
-        default_currency_rate_provider(USD, date(2008, 5, 20), date(2008, 5, 20))
+        default_currency_rate_provider('USD', date(2008, 5, 20), date(2008, 5, 20))
     except xmlrpc.client.Error:
         assert False
 

plugin_examples/yahoo_currency_provider.py

+# This plugin subclasses CurrencyProviderPlugin to provide additional currencies, whose rates are
+# provided by Yahoo.
+
+from core.plugin import CurrencyProviderPlugin
+
+# We use Python's built-in urlopen to hit Yahoo's server and fetch the rates
+from urllib.request import urlopen
+
+# To create a currency provider plugin, one must subclass CurrencyProviderPlugin. You can see more
+# details about how to subclass it in plugins.py. It's accessible online at:
+# https://bitbucket.org/hsoft/moneyguru/src/tip/core/plugin.py
+class YahooProviderPlugin(CurrencyProviderPlugin):
+    NAME = 'Yahoo currency rates fetcher'
+    
+    # First, we must tell moneyGuru what currencies we support. We have to return a list of tuples
+    # containing the code, the name, the decimal precision and a fallback rate for each currencies
+    # we want to support.
+    def register_currencies(self):
+        return [
+            ('XAU', 'Gold (ounce)', 2, 1430.39),
+            ('XAG', 'Silver (ounce)', 2, 23.13),
+        ]
+    
+    # Then, we must implement the rate fetching method. It has to return a float rate that is the
+    # value, today of 1 "currency_code" in CAD (Canadian Dollars).
+    # This method is called asynchronously, it will not block moneyGuru if it takes a long time to
+    # resolve.
+    def get_currency_rate_today(self, currency_code):
+        # the result of this request is a single CSV line like this:
+        # "CADBHD=X",0.3173,"11/7/2008","5:11pm",N/A,N/A,N/A,N/A,N/A 
+        try:
+            url = 'http://download.finance.yahoo.com/d/quotes.csv?s=%sCAD=X&f=sl1d1t1c1ohgv&e=.csv' % currency_code
+            with urlopen(url, timeout=10) as response:
+                content = response.read().decode('latin-1')
+            return float(content.split(',')[1])
+        except Exception:
+            return None
+