GnuCash fec5643+
Loading...
Searching...
No Matches
test_price_and_wrapping.py
1"""Tests for GncPrice / GncPriceDB / GncLot return-type wrapping.
2
3All tests create data programmatically in in-memory sessions so they run
4without any external data files or XML backend.
5"""
6
7import warnings
8from datetime import datetime
9from unittest import TestCase, main
10
11from gnucash import (
12 Account,
13 Book,
14 GncCommodity,
15 GncNumeric,
16 GncPrice,
17 PriceSource,
18 Session,
19 Split,
20 Transaction,
21)
22from gnucash.gnucash_core import GncCommodityNamespace, GncLot, GncPriceDB
23
24
25# ---------------------------------------------------------------------------
26# Helper: set up an in-memory book with a commodity and prices
27# ---------------------------------------------------------------------------
28class PriceSession(TestCase):
29 """Base class that creates a session with a custom commodity and prices."""
30
31 def setUp(self):
32 self.ses = Session()
33 self.book = self.ses.get_book()
34 self.table = self.book.get_table()
35 self.usd = self.table.lookup("CURRENCY", "USD")
36
37 # Create a custom commodity
38 self.test_comm = GncCommodity(
39 self.book, "Test Stock", "NASDAQ", "TSTK", "TSTK", 10000
40 )
41 self.table.insert(self.test_comm)
42
43 # Add prices to the price DB
44 self.pricedb = self.book.get_price_db()
45
46 self.price1 = GncPrice(self.book)
47 self.price1.set_commodity(self.test_comm)
48 self.price1.set_currency(self.usd)
49 self.price1.set_time64(datetime(2025, 1, 15))
50 self.price1.set_value(GncNumeric(4200, 100)) # 42.00
51 self.price1.set_typestr("last")
52 self.pricedb.add_price(self.price1)
53
54 self.price2 = GncPrice(self.book)
55 self.price2.set_commodity(self.test_comm)
56 self.price2.set_currency(self.usd)
57 self.price2.set_time64(datetime(2025, 6, 15))
58 self.price2.set_value(GncNumeric(4500, 100)) # 45.00
59 self.price2.set_typestr("last")
60 self.pricedb.add_price(self.price2)
61
62 def tearDown(self):
63 self.ses.end()
64
65
66# ---------------------------------------------------------------------------
67# Test: GncPrice and GncPriceDB wrapping
68# ---------------------------------------------------------------------------
70 """Verify that GncPrice / GncPriceDB methods return properly wrapped
71 Python objects instead of raw SwigPyObjects."""
72
73 # -- GncPriceDB single-price lookups --
74
75 def test_lookup_latest_returns_gnc_price(self):
76 price = self.pricedb.lookup_latest(self.test_commtest_comm, self.usd)
77 self.assertIsNotNone(price, "No price found for TSTK/USD")
78 self.assertIsInstance(price, GncPrice)
79
80 def test_nth_price_returns_gnc_price(self):
81 price = self.pricedb.nth_price(self.test_commtest_comm, 0)
82 self.assertIsNotNone(price, "nth_price(TSTK, 0) returned None")
83 self.assertIsInstance(price, GncPrice)
84
85 # -- GncPrice attribute methods --
86
87 def test_get_commodity_returns_gnc_commodity(self):
88 price = self.pricedb.lookup_latest(self.test_commtest_comm, self.usd)
89 self.assertIsNotNone(price)
90 comm = price.get_commodity()
91 self.assertIsInstance(comm, GncCommodity)
92
93 def test_get_currency_returns_gnc_commodity(self):
94 price = self.pricedb.lookup_latest(self.test_commtest_comm, self.usd)
95 self.assertIsNotNone(price)
96 curr = price.get_currency()
97 self.assertIsInstance(curr, GncCommodity)
98
99 def test_get_value_returns_gnc_numeric(self):
100 price = self.pricedb.lookup_latest(self.test_commtest_comm, self.usd)
101 self.assertIsNotNone(price)
102 val = price.get_value()
103 self.assertIsInstance(val, GncNumeric)
104
105 def test_clone_returns_gnc_price(self):
106 price = self.pricedb.lookup_latest(self.test_commtest_comm, self.usd)
107 self.assertIsNotNone(price)
108 cloned = price.clone(self.book)
109 self.assertIsInstance(cloned, GncPrice)
110
111 # -- GncPriceDB list methods --
112
113 def test_lookup_latest_any_currency_returns_list_of_gnc_price(self):
114 prices = self.pricedb.lookup_latest_any_currency(self.test_commtest_comm)
115 self.assertIsInstance(prices, list)
116 self.assertGreater(len(prices), 0,
117 "Expected at least one price for TSTK")
118 for p in prices:
119 self.assertIsInstance(p, GncPrice)
120
121 def test_get_prices_returns_list_of_gnc_price(self):
122 prices = self.pricedb.get_prices(self.test_commtest_comm, self.usd)
123 self.assertIsInstance(prices, list)
124 self.assertGreater(len(prices), 0)
125 for p in prices:
126 self.assertIsInstance(p, GncPrice)
127
128 def test_lookup_nearest_in_time_any_currency(self):
129 date = datetime(2025, 1, 20)
130 prices = self.pricedb.lookup_nearest_in_time_any_currency_t64(
131 self.test_commtest_comm, date)
132 self.assertIsInstance(prices, list)
133 for p in prices:
134 self.assertIsInstance(p, GncPrice)
135
136 def test_lookup_nearest_before_any_currency(self):
137 date = datetime(2025, 7, 1)
138 prices = self.pricedb.lookup_nearest_before_any_currency_t64(
139 self.test_commtest_comm, date)
140 self.assertIsInstance(prices, list)
141 for p in prices:
142 self.assertIsInstance(p, GncPrice)
143
144
145# ---------------------------------------------------------------------------
146# Test: GncLot.get_split_list
147# ---------------------------------------------------------------------------
148class TestGncLotSplitList(TestCase):
149 """Verify that GncLot.get_split_list() returns wrapped Split objects.
150
151 Creates a buy+sell pair on an account then scrubs lots, matching the
152 pattern in test_account.py's test_assignlots.
153 """
154
155 def setUp(self):
156 self.ses = Session()
157 self.book = self.ses.get_book()
158 table = self.book.get_table()
159 currency = table.lookup("CURRENCY", "USD")
160
161 stock = GncCommodity(self.book, "Lot Test", "COMMODITY", "LTX", "LTX", 100000)
162 table.insert(stock)
163
164 self.stock_acct = Account(self.book)
165 self.stock_acct.SetCommodity(stock)
166 root = self.book.get_root_account()
167 root.append_child(self.stock_acct)
168
169 cash_acct = Account(self.book)
170 cash_acct.SetCommodity(currency)
171 root.append_child(cash_acct)
172
173 tx = Transaction(self.book)
174 tx.BeginEdit()
175 tx.SetCurrency(currency)
176 tx.SetDateEnteredSecs(datetime.now())
177 tx.SetDatePostedSecs(datetime.now())
178
179 # Buy 1.3 shares
180 s1 = Split(self.book)
181 s1.SetParent(tx)
182 s1.SetAccount(self.stock_acct)
183 s1.SetAmount(GncNumeric(13, 10))
184 s1.SetValue(GncNumeric(100, 1))
185
186 s2 = Split(self.book)
187 s2.SetParent(tx)
188 s2.SetAccount(cash_acct)
189 s2.SetAmount(GncNumeric(-100, 1))
190 s2.SetValue(GncNumeric(-100, 1))
191
192 # Sell 1.3 shares
193 s3 = Split(self.book)
194 s3.SetParent(tx)
195 s3.SetAccount(self.stock_acct)
196 s3.SetAmount(GncNumeric(-13, 10))
197 s3.SetValue(GncNumeric(-100, 1))
198
199 s4 = Split(self.book)
200 s4.SetParent(tx)
201 s4.SetAccount(cash_acct)
202 s4.SetAmount(GncNumeric(100, 1))
203 s4.SetValue(GncNumeric(100, 1))
204
205 tx.CommitEdit()
206 self.stock_acct.ScrubLots()
207
208 def tearDown(self):
209 self.ses.end()
210
211 def test_lot_exists(self):
212 lots = self.stock_acct.GetLotList()
213 self.assertIsInstance(lots, list)
214 self.assertGreater(len(lots), 0, "ScrubLots should have created a lot")
215 for lot in lots:
216 self.assertIsInstance(lot, GncLot)
217
218 def test_get_split_list_returns_splits(self):
219 lots = self.stock_acct.GetLotList()
220 self.assertGreater(len(lots), 0)
221 splits = lots[0].get_split_list()
222 self.assertIsInstance(splits, list)
223 self.assertGreater(len(splits), 0, "Lot has no splits")
224 for s in splits:
225 self.assertIsInstance(s, Split)
226
227
228# ---------------------------------------------------------------------------
229# Test: Split.GetNoclosingBalance and Split.GetCapGains return GncNumeric
230# ---------------------------------------------------------------------------
232 """Verify that Split methods returning gnc_numeric by value are wrapped."""
233
234 def setUp(self):
235 self.ses = Session()
236 self.book = self.ses.get_book()
237 table = self.book.get_table()
238 currency = table.lookup("CURRENCY", "USD")
239
240 root = self.book.get_root_account()
241 acct = Account(self.book)
242 acct.SetCommodity(currency)
243 root.append_child(acct)
244
245 other = Account(self.book)
246 other.SetCommodity(currency)
247 root.append_child(other)
248
249 tx = Transaction(self.book)
250 tx.BeginEdit()
251 tx.SetCurrency(currency)
252 tx.SetDateEnteredSecs(datetime.now())
253 tx.SetDatePostedSecs(datetime.now())
254
255 self.split = Split(self.book)
256 self.split.SetParent(tx)
257 self.split.SetAccount(acct)
258 self.split.SetAmount(GncNumeric(100, 1))
259 self.split.SetValue(GncNumeric(100, 1))
260
261 s2 = Split(self.book)
262 s2.SetParent(tx)
263 s2.SetAccount(other)
264 s2.SetAmount(GncNumeric(-100, 1))
265 s2.SetValue(GncNumeric(-100, 1))
266
267 tx.CommitEdit()
268
269 def tearDown(self):
270 self.ses.end()
271
272 def test_get_noclosing_balance_returns_gnc_numeric(self):
273 val = self.split.GetNoclosingBalance()
274 self.assertIsInstance(val, GncNumeric)
275
276 def test_get_cap_gains_returns_gnc_numeric(self):
277 val = self.split.GetCapGains()
278 self.assertIsInstance(val, GncNumeric)
279
280
281# ---------------------------------------------------------------------------
282# Test: Account.get_currency_or_parent
283# ---------------------------------------------------------------------------
285 """Verify Account.get_currency_or_parent() returns GncCommodity."""
286
287 def setUp(self):
288 self.ses = Session()
289 self.book = self.ses.get_book()
290 table = self.book.get_table()
291 self.usd = table.lookup("CURRENCY", "USD")
292
293 root = self.book.get_root_account()
294 self.acct = Account(self.book)
295 self.acct.SetCommodity(self.usd)
296 root.append_child(self.acct)
297
298 def tearDown(self):
299 self.ses.end()
300
301 def test_get_currency_or_parent_returns_commodity(self):
302 result = self.acct.get_currency_or_parent()
303 self.assertIsNotNone(result)
304 self.assertIsInstance(result, GncCommodity)
305
306
307# ---------------------------------------------------------------------------
308# Test: GncCommodity.obtain_twin wrapping
309# ---------------------------------------------------------------------------
311 """Verify GncCommodity.obtain_twin(book) returns GncCommodity."""
312
313 def test_obtain_twin_same_book(self):
314 ses = Session()
315 book = ses.get_book()
316 table = book.get_table()
317 usd = table.lookup("CURRENCY", "USD")
318 self.assertIsNotNone(usd)
319 twin = usd.obtain_twin(book)
320 self.assertIsInstance(twin, GncCommodity)
321 ses.end()
322
323
324# ---------------------------------------------------------------------------
325# Test: GncCommodity.get_namespace_ds wrapping
326# ---------------------------------------------------------------------------
328 """Verify GncCommodity.get_namespace_ds() returns
329 GncCommodityNamespace."""
330
331 def test_get_namespace_ds(self):
332 ses = Session()
333 book = ses.get_book()
334 table = book.get_table()
335 usd = table.lookup("CURRENCY", "USD")
336 self.assertIsNotNone(usd)
337 ns = usd.get_namespace_ds()
338 self.assertIsInstance(ns, GncCommodityNamespace)
339 ses.end()
340
341
342# ---------------------------------------------------------------------------
343# Test: SWIG typemap compatibility (wrapper → instance unwrap)
344# ---------------------------------------------------------------------------
346 """Verify that passing a ClassFromFunctions wrapper to a C function
347 still works (via the SWIG typemap) and emits a DeprecationWarning."""
348
350 """Passing a GncPrice wrapper to gnucash_core_c should emit
351 DeprecationWarning and still return a valid result."""
352 from gnucash import gnucash_core_c as gc
353
354 price = self.pricedb.lookup_latest(self.test_comm, self.usd)
355 if price is None:
356 self.skipTest("No price data")
357
358 self.assertIsInstance(price, GncPrice)
359
360 with warnings.catch_warnings(record=True) as w:
361 warnings.simplefilter("always")
362 # Pass the wrapper object directly — typemap should unwrap it
363 comm_instance = gc.gnc_price_get_commodity(price)
364 dep_warnings = [x for x in w
365 if issubclass(x.category, DeprecationWarning)]
366 self.assertGreater(len(dep_warnings), 0,
367 "Expected DeprecationWarning from typemap")
368
370 """Passing price.instance directly should NOT emit a warning."""
371 from gnucash import gnucash_core_c as gc
372
373 price = self.pricedb.lookup_latest(self.test_comm, self.usd)
374 if price is None:
375 self.skipTest("No price data")
376
377 with warnings.catch_warnings(record=True) as w:
378 warnings.simplefilter("always")
379 comm_instance = gc.gnc_price_get_commodity(price.instance)
380 dep_warnings = [x for x in w
381 if issubclass(x.category, DeprecationWarning)]
382 self.assertEqual(len(dep_warnings), 0,
383 "Unexpected DeprecationWarning for .instance")
384
385
386# ---------------------------------------------------------------------------
387# Test: GncPriceDB.get_*_price returns GncNumeric
388# ---------------------------------------------------------------------------
390 """Verify that get_latest_price, get_nearest_price, and
391 get_nearest_before_price return GncNumeric instead of raw
392 _gnc_numeric."""
393
394 def test_get_latest_price_returns_gnc_numeric(self):
395 val = self.pricedb.get_latest_price(self.test_commtest_comm, self.usdusd)
396 self.assertIsInstance(val, GncNumeric)
397 self.assertNotEqual(float(val), 0.0,
398 "Expected a non-zero price for TSTK/USD")
399
400 def test_get_nearest_price_returns_gnc_numeric(self):
401 date = datetime(2025, 1, 20)
402 val = self.pricedb.get_nearest_price(self.test_commtest_comm, self.usdusd, date)
403 self.assertIsInstance(val, GncNumeric)
404
405 def test_get_nearest_before_price_returns_gnc_numeric(self):
406 date = datetime(2025, 7, 1)
407 val = self.pricedb.get_nearest_before_price(
408 self.test_commtest_comm, self.usdusd, date)
409 self.assertIsInstance(val, GncNumeric)
410
412 """Verify the returned GncNumeric supports arithmetic."""
413 val = self.pricedb.get_latest_price(self.test_commtest_comm, self.usdusd)
414 doubled = val + val
415 self.assertIsInstance(doubled, GncNumeric)
416 self.assertAlmostEqual(float(doubled), float(val) * 2, places=6)
417
418
419# ---------------------------------------------------------------------------
420# Test: ClassFromFunctions double-wrap protection
421# ---------------------------------------------------------------------------
423 """Verify that passing a wrapper object as instance= to a wrapper
424 class constructor unwraps it instead of creating a broken object."""
425
426 def test_gnc_numeric_double_wrap(self):
427 original = GncNumeric(7, 3)
428 double = GncNumeric(instance=original)
429 self.assertEqual(double.num(), 7)
430 self.assertEqual(double.denom(), 3)
431
432 def test_gnc_numeric_double_wrap_arithmetic(self):
433 original = GncNumeric(1, 4)
434 double = GncNumeric(instance=original)
435 result = double + GncNumeric(3, 4)
436 self.assertAlmostEqual(float(result), 1.0, places=6)
437
438 def test_gnc_commodity_double_wrap(self):
439 ses = Session()
440 book = ses.get_book()
441 table = book.get_table()
442 usd = table.lookup("CURRENCY", "USD")
443 double = GncCommodity(instance=usd)
444 self.assertIsInstance(double, GncCommodity)
445 self.assertEqual(double.get_mnemonic(), "USD")
446 ses.end()
447
449 """Passing a raw SWIG proxy as instance= must still work."""
450 from gnucash import gnucash_core_c as gc
451 raw = gc.gnc_numeric_create(5, 2)
452 val = GncNumeric(instance=raw)
453 self.assertEqual(val.num(), 5)
454 self.assertEqual(val.denom(), 2)
455
456
457# ---------------------------------------------------------------------------
458# Test: PriceSource enum stays in sync with the C PRICE_SOURCE_* enum
459# ---------------------------------------------------------------------------
460class TestPriceSourceEnum(TestCase):
461 """PriceSource must cover the C PRICE_SOURCE_* enum exactly, so it can't
462 drift if a value is added, removed, or renumbered on the C side. Member
463 names are a Python-side choice (e.g. FINANCE_QUOTE spells out FQ), so the
464 check is on the set of values, which come straight from the C enum."""
465
466 def test_values_match_c_enum(self):
467 from gnucash import gnucash_core_c as c
468 c_values = sorted(getattr(c, n) for n in dir(c)
469 if n.startswith("PRICE_SOURCE_"))
470 py_values = sorted(m.value for m in PriceSource)
471 self.assertEqual(py_values, c_values,
472 "PriceSource is out of sync with the C PRICE_SOURCE_* "
473 "enum -- add/remove members to match.")
474
475 def test_set_source_accepts_enum(self):
476 ses = Session()
477 book = ses.get_book()
478 table = book.get_table()
479 usd = table.lookup("CURRENCY", "USD")
480 stock = GncCommodity(book, "Test Stock", "NASDAQ", "TSTK", "TSTK", 10000)
481 table.insert(stock)
482 price = GncPrice(book)
483 price.set_commodity(stock)
484 price.set_currency(usd)
485 price.set_source(PriceSource.FINANCE_QUOTE)
486 self.assertEqual(price.get_source(), PriceSource.FINANCE_QUOTE)
487 self.assertEqual(price.get_source_string(), "Finance::Quote")
488 ses.end()
489
490
491# ---------------------------------------------------------------------------
492# Test: GncPrice.set_source_string -- deprecated, but still guards (raises on
493# an unrecognized source) during the deprecation period
494# ---------------------------------------------------------------------------
496 """set_source_string() is deprecated in favour of set_source(PriceSource.*),
497 but while it exists it must fail loud on an unrecognized source string
498 instead of silently leaving the source unchanged (the C setter no-ops).
499 These tests ignore the DeprecationWarning and check the guard behaviour;
500 the warning itself is covered by test_emits_deprecation_warning."""
501
502 def setUp(self):
503 super().setUp()
504 # These tests deliberately call the deprecated method.
505 self._warn_ctx = warnings.catch_warnings()
506 self._warn_ctx.__enter__()
507 warnings.simplefilter("ignore", DeprecationWarning)
508
509 def tearDown(self):
510 self._warn_ctx.__exit__(None, None, None)
511 super().tearDown()
512
513 def test_canonical_source_is_accepted(self):
514 self.price1.set_source_string("Finance::Quote")
515 self.assertEqual(self.price1.get_source_string(), "Finance::Quote")
516
517 def test_unknown_source_on_fresh_price_raises(self):
518 price = GncPrice(self.book)
519 with self.assertRaises(ValueError):
520 price.set_source_string("not-a-real-source")
521
522 def test_unknown_source_does_not_clobber_existing(self):
523 # The C setter no-ops on an unknown string; on a price that already has
524 # a valid source that leaves the old value in place (so a bare "is the
525 # source INVALID now?" check would miss it). The guard must still raise.
526 self.price1.set_source_string("Finance::Quote")
527 with self.assertRaises(ValueError):
528 self.price1.set_source_string("totally-bogus")
529 self.assertEqual(self.price1.get_source_string(), "Finance::Quote")
530
531 def test_emits_deprecation_warning(self):
532 with warnings.catch_warnings():
533 warnings.simplefilter("error", DeprecationWarning)
534 with self.assertRaises(DeprecationWarning):
535 self.price1.set_source_string("Finance::Quote")
536
537
538if __name__ == '__main__':
539 main()