Skip to content

Commit

Permalink
Fix Python GncNumeric for non (int, int) pairs
Browse files Browse the repository at this point in the history
At current the Python GncNumeric has issues with type conversion eg.
 * GncNumeric(1.3) = 1.00
 * GncNumeric("1.3") is OK but any future methods error

This behaviour was relied on for the Account tests to pass as it used
GncNumeric(0.5) == GncNumeric(1.0) but this is not what many users would
expect.

This fix alows GncNumeric to be constructed from a (int, int)
numerator/denominator pair or int/float/str where double_to_gnc_numeric
and string_to_gnc_numeric from C is used.
  • Loading branch information
TheBiggerGuy authored and jralls committed Nov 29, 2017
1 parent 5aa048e commit 1ef379a
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 20 deletions.
66 changes: 49 additions & 17 deletions bindings/python/gnucash_core.py
Expand Up @@ -42,7 +42,9 @@
gncTaxTableLookup, gncTaxTableLookupByName, gnc_search_invoice_on_id, \
gnc_search_customer_on_id, gnc_search_bill_on_id , \
gnc_search_vendor_on_id, gncInvoiceNextID, gncCustomerNextID, \
gncVendorNextID, gncTaxTableGetTables
gncVendorNextID, gncTaxTableGetTables, gnc_numeric_zero, \
gnc_numeric_create, double_to_gnc_numeric, string_to_gnc_numeric, \
gnc_numeric_to_string

class GnuCashCoreClass(ClassFromFunctions):
_module = gnucash_core_c
Expand Down Expand Up @@ -271,26 +273,56 @@ class GncNumeric(GnuCashCoreClass):
Look at gnc-numeric.h to see how to use these
"""

def __init__(self, num=0, denom=1, **kargs):
"""Constructor that allows you to set the numerator and denominator or
leave them blank with a default value of 0 (not a good idea since there
is currently no way to alter the value after instantiation)
def __init__(self, *args, **kargs):
"""Constructor that supports the following formats:
* No arguments defaulting to zero: eg. GncNumeric() == 0/1
* A integer: e.g. GncNumeric(1) == 1/1
* Numerator and denominator intager pair: eg. GncNumeric(1, 2) == 1/2
* A floating point number: e.g. GncNumeric(0.5) == 1/2
* A floating point number with defined conversion: e.g.
GncNumeric(0.5, GNC_DENOM_AUTO,
GNC_HOW_DENOM_FIXED | GNC_HOW_RND_NEVER) == 1/2
* A string: e.g. GncNumeric("1/2") == 1/2
"""
GnuCashCoreClass.__init__(self, num, denom, **kargs)
#if INSTANCE_ARG in kargs:
# GnuCashCoreClass.__init__(**kargs)
#else:
# self.set_denom(denom) # currently undefined
# self.set_num(num) # currently undefined
if 'instance' not in kargs:
kargs['instance'] = GncNumeric.__args_to_instance(args)
GnuCashCoreClass.__init__(self, [], **kargs)

@staticmethod
def __args_to_instance(args):
if len(args) == 0:
return gnc_numeric_zero()
elif len(args) == 1:
arg = args[0]
if type(arg) == int:
return gnc_numeric_create(arg ,1)
elif type(arg) == float:
return double_to_gnc_numeric(arg, GNC_DENOM_AUTO, GNC_HOW_DENOM_FIXED | GNC_HOW_RND_NEVER)
elif type(arg) == str:
instance = gnc_numeric_zero()
if not string_to_gnc_numeric(arg, instance):
raise TypeError('Failed to convert to GncNumeric: ' + str(args))
return instance
else:
raise TypeError('Only single int/float/str allowed: ' + str(args))
elif len(args) == 2:
if type(args[0]) == int and type(args[1]) == int:
return gnc_numeric_create(*args)
else:
raise TypeError('Only two ints allowed: ' + str(args))
elif len(args) == 3:
if type(args[0]) == float \
and type(args[1]) == type(GNC_DENOM_AUTO) \
and type(args[2]) == type(GNC_HOW_DENOM_FIXED):
return double_to_gnc_numeric(*args)
else:
raise TypeError('Only (float, GNC_HOW_RND_*, GNC_HOW_RND_*, GNC_HOW_RND_*) allowed: ' + str(args))
else:
raise TypeError('Required single int/float/str or two ints: ' + str(args))

def __unicode__(self):
"""Returns a human readable numeric value string as UTF8."""
if self.denom() == 0:
return "Division by zero"
else:
value_float = self.to_double()
value_str = u"{0:.{1}f}".format(value_float,2) ## The second argument is the precision. It would be nice to be able to make it configurable.
return value_str
return gnc_numeric_to_string(self.instance)

def __str__(self):
"""returns a human readable numeric value string as bytes."""
Expand Down
3 changes: 2 additions & 1 deletion bindings/python/tests/runTests.py.in
Expand Up @@ -13,9 +13,10 @@ from test_split import TestSplit
from test_transaction import TestTransaction
from test_business import TestBusiness
from test_commodity import TestCommodity, TestCommodityNamespace
from test_numeric import TestGncNumeric

def test_main():
test_support.run_unittest(TestBook, TestAccount, TestSplit, TestTransaction, TestBusiness, TestCommodity, TestCommodityNamespace)
test_support.run_unittest(TestBook, TestAccount, TestSplit, TestTransaction, TestBusiness, TestCommodity, TestCommodityNamespace, TestGncNumeric)

if __name__ == '__main__':
test_main()
4 changes: 2 additions & 2 deletions bindings/python/tests/test_account.py
Expand Up @@ -40,7 +40,7 @@ def test_assignlots(self):
s1a = Split(self.book)
s1a.SetParent(tx)
s1a.SetAccount(self.account)
s1a.SetAmount(GncNumeric(1.0))
s1a.SetAmount(GncNumeric(1.3))
s1a.SetValue(GncNumeric(100.0))

s1b = Split(self.book)
Expand All @@ -52,7 +52,7 @@ def test_assignlots(self):
s2a = Split(self.book)
s2a.SetParent(tx)
s2a.SetAccount(self.account)
s2a.SetAmount(GncNumeric(-0.5))
s2a.SetAmount(GncNumeric(-1.3))
s2a.SetValue(GncNumeric(-100.0))

s2b = Split(self.book)
Expand Down
96 changes: 96 additions & 0 deletions src/optional/python-bindings/tests/test_numeric.py
@@ -0,0 +1,96 @@
from unittest import TestCase, main

from gnucash import GncNumeric, GNC_DENOM_AUTO, GNC_HOW_DENOM_FIXED, \
GNC_HOW_RND_NEVER, GNC_HOW_RND_FLOOR, GNC_HOW_RND_CEIL

class TestGncNumeric( TestCase ):
def test_defaut(self):
num = GncNumeric()
self.assertEqual(str(num), "0/1")
self.assertEqual(num.num(), 0)
self.assertEqual(num.denom(), 1)

def test_from_num_denom(self):
num = GncNumeric(1, 2)
self.assertEqual(str(num), "1/2")
self.assertEqual(num.num(), 1)
self.assertEqual(num.denom(), 2)

def test_from_int(self):
num = GncNumeric(3)
self.assertEqual(str(num), "3/1")
self.assertEqual(num.num(), 3)
self.assertEqual(num.denom(), 1)

# Safest outcome at current. This can be fixed but correct bounds checks
# are required to ensure gint64 is not overflowed.
def test_from_long(self):
with self.assertRaises(TypeError):
GncNumeric(3L)

def test_from_float(self):
num = GncNumeric(3.1, 20, GNC_HOW_DENOM_FIXED | GNC_HOW_RND_NEVER)
self.assertEqual(str(num), "62/20")
self.assertEqual(num.num(), 62)
self.assertEqual(num.denom(), 20)

num = GncNumeric(1/3.0, 10000000000, GNC_HOW_RND_FLOOR)
self.assertEqual(str(num), "3333333333/10000000000")
self.assertEqual(num.num(), 3333333333)
self.assertEqual(num.denom(), 10000000000)

num = GncNumeric(1/3.0, 10000000000, GNC_HOW_RND_CEIL)
self.assertEqual(str(num), "3333333334/10000000000")
self.assertEqual(num.num(), 3333333334)
self.assertEqual(num.denom(), 10000000000)

def test_from_float_auto(self):
num = GncNumeric(3.1)
self.assertEqual(str(num), "31/10")
self.assertEqual(num.num(), 31)
self.assertEqual(num.denom(), 10)

def test_from_instance(self):
orig = GncNumeric(3)
num = GncNumeric(instance=orig.instance)
self.assertEqual(str(num), "3/1")
self.assertEqual(num.num(), 3)
self.assertEqual(num.denom(), 1)

def test_from_str(self):
num = GncNumeric("3.1")
self.assertEqual(str(num), "31/10")
self.assertEqual(num.num(), 31)
self.assertEqual(num.denom(), 10)

num = GncNumeric("1/3")
self.assertEqual(str(num), "1/3")
self.assertEqual(num.num(), 1)
self.assertEqual(num.denom(), 3)

def test_to_str(self):
num = GncNumeric("1000/3")
self.assertEqual(str(num), "1000/3")

num = GncNumeric(1, 0)
self.assertEqual(str(num), "1/0")

def test_to_double(self):
for test_num in [0.0, 1.1, -1.1, 1/3.0]:
self.assertEqual(GncNumeric(test_num).to_double(), test_num)

def test_incorect_args(self):
with self.assertRaises(TypeError):
GncNumeric(1, 2, 3)

with self.assertRaises(TypeError):
GncNumeric("1", 2)

with self.assertRaises(TypeError):
GncNumeric(1.1, "round")

with self.assertRaises(TypeError):
GncNumeric(complex(1, 1))

if __name__ == '__main__':
main()

0 comments on commit 1ef379a

Please sign in to comment.