GnuCash  5.6-150-g038405b370+
test_price_and_wrapping.py
1 """Tests for GncPrice / GncPriceDB / GncLot return-type wrapping.
2 
3 All tests create data programmatically in in-memory sessions so they run
4 without any external data files or XML backend.
5 """
6 
7 import warnings
8 from datetime import datetime
9 from unittest import TestCase, main
10 
11 from gnucash import (
12  Account,
13  Book,
14  GncCommodity,
15  GncNumeric,
16  GncPrice,
17  Session,
18  Split,
19  Transaction,
20 )
21 from gnucash.gnucash_core import GncCommodityNamespace, GncLot, GncPriceDB
22 
23 
24 # ---------------------------------------------------------------------------
25 # Helper: set up an in-memory book with a commodity and prices
26 # ---------------------------------------------------------------------------
27 class PriceSession(TestCase):
28  """Base class that creates a session with a custom commodity and prices."""
29 
30  def setUp(self):
31  self.ses = Session()
32  self.book = self.ses.get_book()
33  self.table = self.book.get_table()
34  self.usd = self.table.lookup("CURRENCY", "USD")
35 
36  # Create a custom commodity
37  self.test_comm = GncCommodity(
38  self.book, "Test Stock", "NASDAQ", "TSTK", "TSTK", 10000
39  )
40  self.table.insert(self.test_comm)
41 
42  # Add prices to the price DB
43  self.pricedb = self.book.get_price_db()
44 
45  self.price1 = GncPrice(self.book)
46  self.price1.set_commodity(self.test_comm)
47  self.price1.set_currency(self.usd)
48  self.price1.set_time64(datetime(2025, 1, 15))
49  self.price1.set_value(GncNumeric(4200, 100)) # 42.00
50  self.price1.set_typestr("last")
51  self.pricedb.add_price(self.price1)
52 
53  self.price2 = GncPrice(self.book)
54  self.price2.set_commodity(self.test_comm)
55  self.price2.set_currency(self.usd)
56  self.price2.set_time64(datetime(2025, 6, 15))
57  self.price2.set_value(GncNumeric(4500, 100)) # 45.00
58  self.price2.set_typestr("last")
59  self.pricedb.add_price(self.price2)
60 
61  def tearDown(self):
62  self.ses.end()
63 
64 
65 # ---------------------------------------------------------------------------
66 # Test: GncPrice and GncPriceDB wrapping
67 # ---------------------------------------------------------------------------
69  """Verify that GncPrice / GncPriceDB methods return properly wrapped
70  Python objects instead of raw SwigPyObjects."""
71 
72  # -- GncPriceDB single-price lookups --
73 
74  def test_lookup_latest_returns_gnc_price(self):
75  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
76  self.assertIsNotNone(price, "No price found for TSTK/USD")
77  self.assertIsInstance(price, GncPrice)
78 
79  def test_nth_price_returns_gnc_price(self):
80  price = self.pricedb.nth_price(self.test_comm, 0)
81  self.assertIsNotNone(price, "nth_price(TSTK, 0) returned None")
82  self.assertIsInstance(price, GncPrice)
83 
84  # -- GncPrice attribute methods --
85 
86  def test_get_commodity_returns_gnc_commodity(self):
87  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
88  self.assertIsNotNone(price)
89  comm = price.get_commodity()
90  self.assertIsInstance(comm, GncCommodity)
91 
92  def test_get_currency_returns_gnc_commodity(self):
93  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
94  self.assertIsNotNone(price)
95  curr = price.get_currency()
96  self.assertIsInstance(curr, GncCommodity)
97 
98  def test_get_value_returns_gnc_numeric(self):
99  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
100  self.assertIsNotNone(price)
101  val = price.get_value()
102  self.assertIsInstance(val, GncNumeric)
103 
104  def test_clone_returns_gnc_price(self):
105  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
106  self.assertIsNotNone(price)
107  cloned = price.clone(self.book)
108  self.assertIsInstance(cloned, GncPrice)
109 
110  # -- GncPriceDB list methods --
111 
112  def test_lookup_latest_any_currency_returns_list_of_gnc_price(self):
113  prices = self.pricedb.lookup_latest_any_currency(self.test_comm)
114  self.assertIsInstance(prices, list)
115  self.assertGreater(len(prices), 0,
116  "Expected at least one price for TSTK")
117  for p in prices:
118  self.assertIsInstance(p, GncPrice)
119 
120  def test_get_prices_returns_list_of_gnc_price(self):
121  prices = self.pricedb.get_prices(self.test_comm, self.usd)
122  self.assertIsInstance(prices, list)
123  self.assertGreater(len(prices), 0)
124  for p in prices:
125  self.assertIsInstance(p, GncPrice)
126 
127  def test_lookup_nearest_in_time_any_currency(self):
128  date = datetime(2025, 1, 20)
129  prices = self.pricedb.lookup_nearest_in_time_any_currency_t64(
130  self.test_comm, date)
131  self.assertIsInstance(prices, list)
132  for p in prices:
133  self.assertIsInstance(p, GncPrice)
134 
135  def test_lookup_nearest_before_any_currency(self):
136  date = datetime(2025, 7, 1)
137  prices = self.pricedb.lookup_nearest_before_any_currency_t64(
138  self.test_comm, date)
139  self.assertIsInstance(prices, list)
140  for p in prices:
141  self.assertIsInstance(p, GncPrice)
142 
143 
144 # ---------------------------------------------------------------------------
145 # Test: GncLot.get_split_list
146 # ---------------------------------------------------------------------------
147 class TestGncLotSplitList(TestCase):
148  """Verify that GncLot.get_split_list() returns wrapped Split objects.
149 
150  Creates a buy+sell pair on an account then scrubs lots, matching the
151  pattern in test_account.py's test_assignlots.
152  """
153 
154  def setUp(self):
155  self.ses = Session()
156  self.book = self.ses.get_book()
157  table = self.book.get_table()
158  currency = table.lookup("CURRENCY", "USD")
159 
160  stock = GncCommodity(self.book, "Lot Test", "COMMODITY", "LTX", "LTX", 100000)
161  table.insert(stock)
162 
163  self.stock_acct = Account(self.book)
164  self.stock_acct.SetCommodity(stock)
165  root = self.book.get_root_account()
166  root.append_child(self.stock_acct)
167 
168  cash_acct = Account(self.book)
169  cash_acct.SetCommodity(currency)
170  root.append_child(cash_acct)
171 
172  tx = Transaction(self.book)
173  tx.BeginEdit()
174  tx.SetCurrency(currency)
175  tx.SetDateEnteredSecs(datetime.now())
176  tx.SetDatePostedSecs(datetime.now())
177 
178  # Buy 1.3 shares
179  s1 = Split(self.book)
180  s1.SetParent(tx)
181  s1.SetAccount(self.stock_acct)
182  s1.SetAmount(GncNumeric(13, 10))
183  s1.SetValue(GncNumeric(100, 1))
184 
185  s2 = Split(self.book)
186  s2.SetParent(tx)
187  s2.SetAccount(cash_acct)
188  s2.SetAmount(GncNumeric(-100, 1))
189  s2.SetValue(GncNumeric(-100, 1))
190 
191  # Sell 1.3 shares
192  s3 = Split(self.book)
193  s3.SetParent(tx)
194  s3.SetAccount(self.stock_acct)
195  s3.SetAmount(GncNumeric(-13, 10))
196  s3.SetValue(GncNumeric(-100, 1))
197 
198  s4 = Split(self.book)
199  s4.SetParent(tx)
200  s4.SetAccount(cash_acct)
201  s4.SetAmount(GncNumeric(100, 1))
202  s4.SetValue(GncNumeric(100, 1))
203 
204  tx.CommitEdit()
205  self.stock_acct.ScrubLots()
206 
207  def tearDown(self):
208  self.ses.end()
209 
210  def test_lot_exists(self):
211  lots = self.stock_acct.GetLotList()
212  self.assertIsInstance(lots, list)
213  self.assertGreater(len(lots), 0, "ScrubLots should have created a lot")
214  for lot in lots:
215  self.assertIsInstance(lot, GncLot)
216 
217  def test_get_split_list_returns_splits(self):
218  lots = self.stock_acct.GetLotList()
219  self.assertGreater(len(lots), 0)
220  splits = lots[0].get_split_list()
221  self.assertIsInstance(splits, list)
222  self.assertGreater(len(splits), 0, "Lot has no splits")
223  for s in splits:
224  self.assertIsInstance(s, Split)
225 
226 
227 # ---------------------------------------------------------------------------
228 # Test: Split.GetNoclosingBalance and Split.GetCapGains return GncNumeric
229 # ---------------------------------------------------------------------------
231  """Verify that Split methods returning gnc_numeric by value are wrapped."""
232 
233  def setUp(self):
234  self.ses = Session()
235  self.book = self.ses.get_book()
236  table = self.book.get_table()
237  currency = table.lookup("CURRENCY", "USD")
238 
239  root = self.book.get_root_account()
240  acct = Account(self.book)
241  acct.SetCommodity(currency)
242  root.append_child(acct)
243 
244  other = Account(self.book)
245  other.SetCommodity(currency)
246  root.append_child(other)
247 
248  tx = Transaction(self.book)
249  tx.BeginEdit()
250  tx.SetCurrency(currency)
251  tx.SetDateEnteredSecs(datetime.now())
252  tx.SetDatePostedSecs(datetime.now())
253 
254  self.split = Split(self.book)
255  self.split.SetParent(tx)
256  self.split.SetAccount(acct)
257  self.split.SetAmount(GncNumeric(100, 1))
258  self.split.SetValue(GncNumeric(100, 1))
259 
260  s2 = Split(self.book)
261  s2.SetParent(tx)
262  s2.SetAccount(other)
263  s2.SetAmount(GncNumeric(-100, 1))
264  s2.SetValue(GncNumeric(-100, 1))
265 
266  tx.CommitEdit()
267 
268  def tearDown(self):
269  self.ses.end()
270 
271  def test_get_noclosing_balance_returns_gnc_numeric(self):
272  val = self.split.GetNoclosingBalance()
273  self.assertIsInstance(val, GncNumeric)
274 
275  def test_get_cap_gains_returns_gnc_numeric(self):
276  val = self.split.GetCapGains()
277  self.assertIsInstance(val, GncNumeric)
278 
279 
280 # ---------------------------------------------------------------------------
281 # Test: Account.get_currency_or_parent
282 # ---------------------------------------------------------------------------
284  """Verify Account.get_currency_or_parent() returns GncCommodity."""
285 
286  def setUp(self):
287  self.ses = Session()
288  self.book = self.ses.get_book()
289  table = self.book.get_table()
290  self.usd = table.lookup("CURRENCY", "USD")
291 
292  root = self.book.get_root_account()
293  self.acct = Account(self.book)
294  self.acct.SetCommodity(self.usd)
295  root.append_child(self.acct)
296 
297  def tearDown(self):
298  self.ses.end()
299 
300  def test_get_currency_or_parent_returns_commodity(self):
301  result = self.acct.get_currency_or_parent()
302  self.assertIsNotNone(result)
303  self.assertIsInstance(result, GncCommodity)
304 
305 
306 # ---------------------------------------------------------------------------
307 # Test: GncCommodity.obtain_twin wrapping
308 # ---------------------------------------------------------------------------
309 class TestCommodityObtainTwin(TestCase):
310  """Verify GncCommodity.obtain_twin(book) returns GncCommodity."""
311 
312  def test_obtain_twin_same_book(self):
313  ses = Session()
314  book = ses.get_book()
315  table = book.get_table()
316  usd = table.lookup("CURRENCY", "USD")
317  self.assertIsNotNone(usd)
318  twin = usd.obtain_twin(book)
319  self.assertIsInstance(twin, GncCommodity)
320  ses.end()
321 
322 
323 # ---------------------------------------------------------------------------
324 # Test: GncCommodity.get_namespace_ds wrapping
325 # ---------------------------------------------------------------------------
326 class TestCommodityNamespaceDS(TestCase):
327  """Verify GncCommodity.get_namespace_ds() returns
328  GncCommodityNamespace."""
329 
330  def test_get_namespace_ds(self):
331  ses = Session()
332  book = ses.get_book()
333  table = book.get_table()
334  usd = table.lookup("CURRENCY", "USD")
335  self.assertIsNotNone(usd)
336  ns = usd.get_namespace_ds()
337  self.assertIsInstance(ns, GncCommodityNamespace)
338  ses.end()
339 
340 
341 # ---------------------------------------------------------------------------
342 # Test: SWIG typemap compatibility (wrapper → instance unwrap)
343 # ---------------------------------------------------------------------------
345  """Verify that passing a ClassFromFunctions wrapper to a C function
346  still works (via the SWIG typemap) and emits a DeprecationWarning."""
347 
349  """Passing a GncPrice wrapper to gnucash_core_c should emit
350  DeprecationWarning and still return a valid result."""
351  from gnucash import gnucash_core_c as gc
352 
353  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
354  if price is None:
355  self.skipTest("No price data")
356 
357  self.assertIsInstance(price, GncPrice)
358 
359  with warnings.catch_warnings(record=True) as w:
360  warnings.simplefilter("always")
361  # Pass the wrapper object directly — typemap should unwrap it
362  comm_instance = gc.gnc_price_get_commodity(price)
363  dep_warnings = [x for x in w
364  if issubclass(x.category, DeprecationWarning)]
365  self.assertGreater(len(dep_warnings), 0,
366  "Expected DeprecationWarning from typemap")
367 
369  """Passing price.instance directly should NOT emit a warning."""
370  from gnucash import gnucash_core_c as gc
371 
372  price = self.pricedb.lookup_latest(self.test_comm, self.usd)
373  if price is None:
374  self.skipTest("No price data")
375 
376  with warnings.catch_warnings(record=True) as w:
377  warnings.simplefilter("always")
378  comm_instance = gc.gnc_price_get_commodity(price.instance)
379  dep_warnings = [x for x in w
380  if issubclass(x.category, DeprecationWarning)]
381  self.assertEqual(len(dep_warnings), 0,
382  "Unexpected DeprecationWarning for .instance")
383 
384 
385 # ---------------------------------------------------------------------------
386 # Test: GncPriceDB.get_*_price returns GncNumeric
387 # ---------------------------------------------------------------------------
389  """Verify that get_latest_price, get_nearest_price, and
390  get_nearest_before_price return GncNumeric instead of raw
391  _gnc_numeric."""
392 
393  def test_get_latest_price_returns_gnc_numeric(self):
394  val = self.pricedb.get_latest_price(self.test_comm, self.usd)
395  self.assertIsInstance(val, GncNumeric)
396  self.assertNotEqual(float(val), 0.0,
397  "Expected a non-zero price for TSTK/USD")
398 
399  def test_get_nearest_price_returns_gnc_numeric(self):
400  date = datetime(2025, 1, 20)
401  val = self.pricedb.get_nearest_price(self.test_comm, self.usd, date)
402  self.assertIsInstance(val, GncNumeric)
403 
404  def test_get_nearest_before_price_returns_gnc_numeric(self):
405  date = datetime(2025, 7, 1)
406  val = self.pricedb.get_nearest_before_price(
407  self.test_comm, self.usd, date)
408  self.assertIsInstance(val, GncNumeric)
409 
411  """Verify the returned GncNumeric supports arithmetic."""
412  val = self.pricedb.get_latest_price(self.test_comm, self.usd)
413  doubled = val + val
414  self.assertIsInstance(doubled, GncNumeric)
415  self.assertAlmostEqual(float(doubled), float(val) * 2, places=6)
416 
417 
418 # ---------------------------------------------------------------------------
419 # Test: ClassFromFunctions double-wrap protection
420 # ---------------------------------------------------------------------------
421 class TestDoubleWrapProtection(TestCase):
422  """Verify that passing a wrapper object as instance= to a wrapper
423  class constructor unwraps it instead of creating a broken object."""
424 
425  def test_gnc_numeric_double_wrap(self):
426  original = GncNumeric(7, 3)
427  double = GncNumeric(instance=original)
428  self.assertEqual(double.num(), 7)
429  self.assertEqual(double.denom(), 3)
430 
431  def test_gnc_numeric_double_wrap_arithmetic(self):
432  original = GncNumeric(1, 4)
433  double = GncNumeric(instance=original)
434  result = double + GncNumeric(3, 4)
435  self.assertAlmostEqual(float(result), 1.0, places=6)
436 
437  def test_gnc_commodity_double_wrap(self):
438  ses = Session()
439  book = ses.get_book()
440  table = book.get_table()
441  usd = table.lookup("CURRENCY", "USD")
442  double = GncCommodity(instance=usd)
443  self.assertIsInstance(double, GncCommodity)
444  self.assertEqual(double.get_mnemonic(), "USD")
445  ses.end()
446 
448  """Passing a raw SWIG proxy as instance= must still work."""
449  from gnucash import gnucash_core_c as gc
450  raw = gc.gnc_numeric_create(5, 2)
451  val = GncNumeric(instance=raw)
452  self.assertEqual(val.num(), 5)
453  self.assertEqual(val.denom(), 2)
454 
455 
456 if __name__ == '__main__':
457  main()
STRUCTS.
The primary numeric class for representing amounts and values.
Definition: gnc-numeric.hpp:60