gnucash stable: Multiple changes pushed
John Ralls
jralls at code.gnucash.org
Mon Mar 16 18:07:10 EDT 2026
Updated via https://github.com/Gnucash/gnucash/commit/b521f21a (commit)
via https://github.com/Gnucash/gnucash/commit/33daaf43 (commit)
via https://github.com/Gnucash/gnucash/commit/859f0839 (commit)
via https://github.com/Gnucash/gnucash/commit/556e7b0d (commit)
via https://github.com/Gnucash/gnucash/commit/592b57dd (commit)
via https://github.com/Gnucash/gnucash/commit/e4ec72c9 (commit)
from https://github.com/Gnucash/gnucash/commit/be94e9ce (commit)
commit b521f21a1be41596ca929c2f0f019bb4684214b1
Merge: 33daaf4331 859f0839ce
Author: John Ralls <jralls at ceridwen.us>
Date: Mon Mar 16 15:03:31 2026 -0700
Merge No-err's 'pr2-return-type-wrapping' into stable.
commit 33daaf433162d61e01ba6218284ba29b6e1c29bb
Merge: be94e9ce41 e4ec72c97c
Author: John Ralls <jralls at ceridwen.us>
Date: Mon Mar 16 15:01:54 2026 -0700
Merge No-err's 'pr1-swig-typemap-compat' into stable.
commit 859f0839ce727fef6ac484e753569bd9ee793008
Author: Noah R <Noerr at users.noreply.github.com>
Date: Fri Mar 6 15:05:30 2026 -0800
[python-bindings] Add Split wrapping fixes, refactor tests for CI
Add missing gnc_numeric wrapping for two Split methods:
- GetNoclosingBalance â GncNumeric
- GetCapGains â GncNumeric
Refactor test_price_and_wrapping.py to create all test data
programmatically in in-memory sessions, eliminating external data file
dependencies (pricedb1.gml2, sample1.gnucash) and XML backend
requirement. All 27 tests now run unconditionally.
Register tests in CI: add imports to runTests.py.in and add
test_price_and_wrapping.py to CMakeLists.txt dist list.
diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py
index 6bef3ed9b9..61fff6e6b0 100644
--- a/bindings/python/gnucash_core.py
+++ b/bindings/python/gnucash_core.py
@@ -1094,6 +1094,8 @@ split_dict = {
'GetReconciledBalance': GncNumeric,
'VoidFormerAmount': GncNumeric,
'VoidFormerValue': GncNumeric,
+ 'GetNoclosingBalance': GncNumeric,
+ 'GetCapGains': GncNumeric,
'GetGUID': GUID
}
methods_return_instance(Split, split_dict)
diff --git a/bindings/python/tests/CMakeLists.txt b/bindings/python/tests/CMakeLists.txt
index e68500d91d..182204389e 100644
--- a/bindings/python/tests/CMakeLists.txt
+++ b/bindings/python/tests/CMakeLists.txt
@@ -26,6 +26,7 @@ set(test_python_bindings_DATA
test_split.py
test_transaction.py
test_query.py
- test_function_class.py)
+ test_function_class.py
+ test_price_and_wrapping.py)
set_dist_list(test_python_bindings_DIST CMakeLists.txt ${test_python_bindings_DATA})
diff --git a/bindings/python/tests/runTests.py.in b/bindings/python/tests/runTests.py.in
index 5b9142592d..c641a8cd69 100755
--- a/bindings/python/tests/runTests.py.in
+++ b/bindings/python/tests/runTests.py.in
@@ -16,6 +16,17 @@ from test_business import TestBusiness
from test_commodity import TestCommodity, TestCommodityNamespace
from test_numeric import TestGncNumeric
from test_query import TestQuery
+from test_price_and_wrapping import (
+ TestGncPriceWrapping,
+ TestGncLotSplitList,
+ TestSplitGncNumericReturns,
+ TestAccountCurrencyOrParent,
+ TestCommodityObtainTwin,
+ TestCommodityNamespaceDS,
+ TestSwigTypemapCompat,
+ TestGetPriceReturnsGncNumeric,
+ TestDoubleWrapProtection,
+)
if __name__ == '__main__':
unittest.main()
diff --git a/bindings/python/tests/test_price_and_wrapping.py b/bindings/python/tests/test_price_and_wrapping.py
index 9c46b33958..b889b7a8c6 100644
--- a/bindings/python/tests/test_price_and_wrapping.py
+++ b/bindings/python/tests/test_price_and_wrapping.py
@@ -1,152 +1,108 @@
"""Tests for GncPrice / GncPriceDB / GncLot return-type wrapping.
-These tests require an XML backend (i.e. build with -DWITH_GNUCASH=ON or at
-least with the XML backend enabled) because they open on-disk GnuCash files.
-
-Test data comes from files already in the repo:
- - pricedb1.gml2 : 13 commodities, many prices in USD, 1 root account
- - sample1.gnucash : accounts, transactions, splits, 1 lot
+All tests create data programmatically in in-memory sessions so they run
+without any external data files or XML backend.
"""
-import os
-import shutil
-import tempfile
import warnings
from datetime import datetime
-from pathlib import Path
-from unittest import TestCase, main, skipUnless
-from urllib.parse import urlunparse
-
-# Locate test data relative to the source tree. When running from a build
-# directory the source tree is typically the parent; we walk up until we find
-# the marker directory.
-def _find_source_root():
- """Walk up from this file looking for the repo root."""
- d = Path(__file__).resolve().parent
- for _ in range(10):
- if (d / "libgnucash").is_dir():
- return d
- d = d.parent
- # Fall back to environment variable set by CMake / CTest
- builddir = os.environ.get("GNC_BUILDDIR")
- if builddir:
- # source tree is often one level up from build dir
- candidate = Path(builddir).parent
- if (candidate / "libgnucash").is_dir():
- return candidate
- return None
-
-_SRC_ROOT = _find_source_root()
-_PRICEDB_FILE = (
- _SRC_ROOT / "libgnucash" / "backend" / "xml" / "test" / "test-files"
- / "xml2" / "pricedb1.gml2"
-) if _SRC_ROOT else None
-_SAMPLE_FILE = (
- _SRC_ROOT / "libgnucash" / "backend" / "xml" / "test" / "test-files"
- / "load-save" / "sample1.gnucash"
-) if _SRC_ROOT else None
-
-_HAS_TEST_DATA = _PRICEDB_FILE is not None and _PRICEDB_FILE.exists()
-_HAS_SAMPLE_DATA = _SAMPLE_FILE is not None and _SAMPLE_FILE.exists()
-
-
-def _copy_to_tmp(src_path, tmpdir):
- """Copy a GnuCash file into a temp dir and return an xml:// URI."""
- fname = os.path.basename(src_path)
- dest = os.path.join(tmpdir, fname)
- shutil.copy2(str(src_path), dest)
- # URI format: xml://<dir>/<filename> (matches test_session.py convention)
- return urlunparse(("xml", tmpdir, fname, "", "", ""))
-
-
-def _can_open_xml():
- """Return True if the XML backend is available."""
- try:
- from gnucash import Session, SessionOpenMode
- with tempfile.TemporaryDirectory() as tmpdir:
- uri = urlunparse(("xml", tmpdir, "probe", "", "", ""))
- with Session(uri, SessionOpenMode.SESSION_NEW_STORE) as ses:
- pass
- return True
- except Exception:
- return False
+from unittest import TestCase, main
+
+from gnucash import (
+ Account,
+ Book,
+ GncCommodity,
+ GncNumeric,
+ GncPrice,
+ Session,
+ Split,
+ Transaction,
+)
+from gnucash.gnucash_core import GncCommodityNamespace, GncLot, GncPriceDB
# ---------------------------------------------------------------------------
-# Test: GncPrice and GncPriceDB wrapping via pricedb1.gml2
+# Helper: set up an in-memory book with a commodity and prices
# ---------------------------------------------------------------------------
- at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
-class TestGncPriceWrapping(TestCase):
- """Open pricedb1.gml2 and verify that GncPrice / GncPriceDB methods
- return properly wrapped Python objects instead of raw SwigPyObjects."""
-
- @classmethod
- def setUpClass(cls):
- if not _can_open_xml():
- raise unittest.SkipTest("XML backend not available")
- cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_price_")
- from gnucash import Session, SessionOpenMode
- uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
- cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
- cls.book = cls.ses.get_book()
- cls.table = cls.book.get_table()
- cls.pricedb = cls.book.get_price_db()
- # Look up a commodity we know is in the file
- cls.usd = cls.table.lookup("CURRENCY", "USD")
- cls.corl = cls.table.lookup("NASDAQ", "CORL")
-
- @classmethod
- def tearDownClass(cls):
- cls.ses.end()
- shutil.rmtree(cls._tmpdir, ignore_errors=True)
-
- # -- basic sanity --
-
- def test_commodity_lookup(self):
- """Verify we can find the commodities in the test file."""
- self.assertIsNotNone(self.usd)
- self.assertIsNotNone(self.corl)
+class PriceSession(TestCase):
+ """Base class that creates a session with a custom commodity and prices."""
+
+ def setUp(self):
+ self.ses = Session()
+ self.book = self.ses.get_book()
+ self.table = self.book.get_table()
+ self.usd = self.table.lookup("CURRENCY", "USD")
+
+ # Create a custom commodity
+ self.test_comm = GncCommodity(
+ self.book, "Test Stock", "NASDAQ", "TSTK", "TSTK", 10000
+ )
+ self.table.insert(self.test_comm)
+
+ # Add prices to the price DB
+ self.pricedb = self.book.get_price_db()
+
+ self.price1 = GncPrice(self.book)
+ self.price1.set_commodity(self.test_comm)
+ self.price1.set_currency(self.usd)
+ self.price1.set_time64(datetime(2025, 1, 15))
+ self.price1.set_value(GncNumeric(4200, 100)) # 42.00
+ self.price1.set_typestr("last")
+ self.pricedb.add_price(self.price1)
+
+ self.price2 = GncPrice(self.book)
+ self.price2.set_commodity(self.test_comm)
+ self.price2.set_currency(self.usd)
+ self.price2.set_time64(datetime(2025, 6, 15))
+ self.price2.set_value(GncNumeric(4500, 100)) # 45.00
+ self.price2.set_typestr("last")
+ self.pricedb.add_price(self.price2)
+
+ def tearDown(self):
+ self.ses.end()
+
+
+# ---------------------------------------------------------------------------
+# Test: GncPrice and GncPriceDB wrapping
+# ---------------------------------------------------------------------------
+class TestGncPriceWrapping(PriceSession):
+ """Verify that GncPrice / GncPriceDB methods return properly wrapped
+ Python objects instead of raw SwigPyObjects."""
# -- GncPriceDB single-price lookups --
def test_lookup_latest_returns_gnc_price(self):
- from gnucash import GncPrice
- price = self.pricedb.lookup_latest(self.corl, self.usd)
- self.assertIsNotNone(price, "No price found for CORL/USD")
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
+ self.assertIsNotNone(price, "No price found for TSTK/USD")
self.assertIsInstance(price, GncPrice)
def test_nth_price_returns_gnc_price(self):
- from gnucash import GncPrice
- price = self.pricedb.nth_price(self.corl, 0)
- self.assertIsNotNone(price, "nth_price(CORL, 0) returned None")
+ price = self.pricedb.nth_price(self.test_comm, 0)
+ self.assertIsNotNone(price, "nth_price(TSTK, 0) returned None")
self.assertIsInstance(price, GncPrice)
# -- GncPrice attribute methods --
def test_get_commodity_returns_gnc_commodity(self):
- from gnucash import GncPrice, GncCommodity
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
self.assertIsNotNone(price)
comm = price.get_commodity()
self.assertIsInstance(comm, GncCommodity)
def test_get_currency_returns_gnc_commodity(self):
- from gnucash import GncPrice, GncCommodity
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
self.assertIsNotNone(price)
curr = price.get_currency()
self.assertIsInstance(curr, GncCommodity)
def test_get_value_returns_gnc_numeric(self):
- from gnucash import GncPrice, GncNumeric
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
self.assertIsNotNone(price)
val = price.get_value()
self.assertIsInstance(val, GncNumeric)
def test_clone_returns_gnc_price(self):
- from gnucash import GncPrice
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
self.assertIsNotNone(price)
cloned = price.clone(self.book)
self.assertIsInstance(cloned, GncPrice)
@@ -154,86 +110,114 @@ class TestGncPriceWrapping(TestCase):
# -- GncPriceDB list methods --
def test_lookup_latest_any_currency_returns_list_of_gnc_price(self):
- from gnucash import GncPrice
- prices = self.pricedb.lookup_latest_any_currency(self.corl)
+ prices = self.pricedb.lookup_latest_any_currency(self.test_comm)
self.assertIsInstance(prices, list)
- # pricedb1.gml2 has CORL priced in USD so we expect at least 1
self.assertGreater(len(prices), 0,
- "Expected at least one price for CORL")
+ "Expected at least one price for TSTK")
for p in prices:
self.assertIsInstance(p, GncPrice)
def test_get_prices_returns_list_of_gnc_price(self):
- from gnucash import GncPrice
- prices = self.pricedb.get_prices(self.corl, self.usd)
+ prices = self.pricedb.get_prices(self.test_comm, self.usd)
self.assertIsInstance(prices, list)
self.assertGreater(len(prices), 0)
for p in prices:
self.assertIsInstance(p, GncPrice)
def test_lookup_nearest_in_time_any_currency(self):
- from gnucash import GncPrice
- # Prices in pricedb1 are from 2001-03-26
- date = datetime(2001, 3, 26)
+ date = datetime(2025, 1, 20)
prices = self.pricedb.lookup_nearest_in_time_any_currency_t64(
- self.corl, date)
+ self.test_comm, date)
self.assertIsInstance(prices, list)
for p in prices:
self.assertIsInstance(p, GncPrice)
def test_lookup_nearest_before_any_currency(self):
- from gnucash import GncPrice
- date = datetime(2001, 4, 1)
+ date = datetime(2025, 7, 1)
prices = self.pricedb.lookup_nearest_before_any_currency_t64(
- self.corl, date)
+ self.test_comm, date)
self.assertIsInstance(prices, list)
for p in prices:
self.assertIsInstance(p, GncPrice)
# ---------------------------------------------------------------------------
-# Test: GncLot.get_split_list via sample1.gnucash
+# Test: GncLot.get_split_list
# ---------------------------------------------------------------------------
- at skipUnless(_HAS_SAMPLE_DATA, "sample1.gnucash not found in source tree")
class TestGncLotSplitList(TestCase):
- """Open sample1.gnucash and verify that GncLot.get_split_list() returns
- properly wrapped Split objects."""
-
- @classmethod
- def setUpClass(cls):
- if not _can_open_xml():
- raise unittest.SkipTest("XML backend not available")
- cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_lot_")
- from gnucash import Session, SessionOpenMode
- uri = _copy_to_tmp(_SAMPLE_FILE, cls._tmpdir)
- cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
- cls.book = cls.ses.get_book()
-
- @classmethod
- def tearDownClass(cls):
- cls.ses.end()
- shutil.rmtree(cls._tmpdir, ignore_errors=True)
-
- def _find_lot(self):
- """Find the first lot in any account."""
+ """Verify that GncLot.get_split_list() returns wrapped Split objects.
+
+ Creates a buy+sell pair on an account then scrubs lots, matching the
+ pattern in test_account.py's test_assignlots.
+ """
+
+ def setUp(self):
+ self.ses = Session()
+ self.book = self.ses.get_book()
+ table = self.book.get_table()
+ currency = table.lookup("CURRENCY", "USD")
+
+ stock = GncCommodity(self.book, "Lot Test", "COMMODITY", "LTX", "LTX", 100000)
+ table.insert(stock)
+
+ self.stock_acct = Account(self.book)
+ self.stock_acct.SetCommodity(stock)
root = self.book.get_root_account()
- for acct in root.get_descendants():
- lots = acct.GetLotList()
- if lots:
- return lots[0]
- return None
+ root.append_child(self.stock_acct)
+
+ cash_acct = Account(self.book)
+ cash_acct.SetCommodity(currency)
+ root.append_child(cash_acct)
+
+ tx = Transaction(self.book)
+ tx.BeginEdit()
+ tx.SetCurrency(currency)
+ tx.SetDateEnteredSecs(datetime.now())
+ tx.SetDatePostedSecs(datetime.now())
+
+ # Buy 1.3 shares
+ s1 = Split(self.book)
+ s1.SetParent(tx)
+ s1.SetAccount(self.stock_acct)
+ s1.SetAmount(GncNumeric(13, 10))
+ s1.SetValue(GncNumeric(100, 1))
+
+ s2 = Split(self.book)
+ s2.SetParent(tx)
+ s2.SetAccount(cash_acct)
+ s2.SetAmount(GncNumeric(-100, 1))
+ s2.SetValue(GncNumeric(-100, 1))
+
+ # Sell 1.3 shares
+ s3 = Split(self.book)
+ s3.SetParent(tx)
+ s3.SetAccount(self.stock_acct)
+ s3.SetAmount(GncNumeric(-13, 10))
+ s3.SetValue(GncNumeric(-100, 1))
+
+ s4 = Split(self.book)
+ s4.SetParent(tx)
+ s4.SetAccount(cash_acct)
+ s4.SetAmount(GncNumeric(100, 1))
+ s4.SetValue(GncNumeric(100, 1))
+
+ tx.CommitEdit()
+ self.stock_acct.ScrubLots()
+
+ def tearDown(self):
+ self.ses.end()
def test_lot_exists(self):
- """sample1.gnucash should have at least one lot."""
- lot = self._find_lot()
- self.assertIsNotNone(lot, "No lots found in sample1.gnucash")
+ lots = self.stock_acct.GetLotList()
+ self.assertIsInstance(lots, list)
+ self.assertGreater(len(lots), 0, "ScrubLots should have created a lot")
+ for lot in lots:
+ self.assertIsInstance(lot, GncLot)
def test_get_split_list_returns_splits(self):
- from gnucash import Split
- lot = self._find_lot()
- if lot is None:
- self.skipTest("No lots in sample1.gnucash")
- splits = lot.get_split_list()
+ lots = self.stock_acct.GetLotList()
+ self.assertGreater(len(lots), 0)
+ splits = lots[0].get_split_list()
self.assertIsInstance(splits, list)
self.assertGreater(len(splits), 0, "Lot has no splits")
for s in splits:
@@ -241,40 +225,82 @@ class TestGncLotSplitList(TestCase):
# ---------------------------------------------------------------------------
-# Test: Account.get_currency_or_parent via sample1.gnucash
+# Test: Split.GetNoclosingBalance and Split.GetCapGains return GncNumeric
+# ---------------------------------------------------------------------------
+class TestSplitGncNumericReturns(TestCase):
+ """Verify that Split methods returning gnc_numeric by value are wrapped."""
+
+ def setUp(self):
+ self.ses = Session()
+ self.book = self.ses.get_book()
+ table = self.book.get_table()
+ currency = table.lookup("CURRENCY", "USD")
+
+ root = self.book.get_root_account()
+ acct = Account(self.book)
+ acct.SetCommodity(currency)
+ root.append_child(acct)
+
+ other = Account(self.book)
+ other.SetCommodity(currency)
+ root.append_child(other)
+
+ tx = Transaction(self.book)
+ tx.BeginEdit()
+ tx.SetCurrency(currency)
+ tx.SetDateEnteredSecs(datetime.now())
+ tx.SetDatePostedSecs(datetime.now())
+
+ self.split = Split(self.book)
+ self.split.SetParent(tx)
+ self.split.SetAccount(acct)
+ self.split.SetAmount(GncNumeric(100, 1))
+ self.split.SetValue(GncNumeric(100, 1))
+
+ s2 = Split(self.book)
+ s2.SetParent(tx)
+ s2.SetAccount(other)
+ s2.SetAmount(GncNumeric(-100, 1))
+ s2.SetValue(GncNumeric(-100, 1))
+
+ tx.CommitEdit()
+
+ def tearDown(self):
+ self.ses.end()
+
+ def test_get_noclosing_balance_returns_gnc_numeric(self):
+ val = self.split.GetNoclosingBalance()
+ self.assertIsInstance(val, GncNumeric)
+
+ def test_get_cap_gains_returns_gnc_numeric(self):
+ val = self.split.GetCapGains()
+ self.assertIsInstance(val, GncNumeric)
+
+
+# ---------------------------------------------------------------------------
+# Test: Account.get_currency_or_parent
# ---------------------------------------------------------------------------
- at skipUnless(_HAS_SAMPLE_DATA, "sample1.gnucash not found in source tree")
class TestAccountCurrencyOrParent(TestCase):
"""Verify Account.get_currency_or_parent() returns GncCommodity."""
- @classmethod
- def setUpClass(cls):
- if not _can_open_xml():
- raise unittest.SkipTest("XML backend not available")
- cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_acct_")
- from gnucash import Session, SessionOpenMode
- uri = _copy_to_tmp(_SAMPLE_FILE, cls._tmpdir)
- cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
- cls.book = cls.ses.get_book()
-
- @classmethod
- def tearDownClass(cls):
- cls.ses.end()
- shutil.rmtree(cls._tmpdir, ignore_errors=True)
+ def setUp(self):
+ self.ses = Session()
+ self.book = self.ses.get_book()
+ table = self.book.get_table()
+ self.usd = table.lookup("CURRENCY", "USD")
- def test_get_currency_or_parent_returns_commodity(self):
- from gnucash import GncCommodity
root = self.book.get_root_account()
- descendants = root.get_descendants()
- self.assertGreater(len(descendants), 0)
- found_one = False
- for acct in descendants:
- result = acct.get_currency_or_parent()
- if result is not None:
- self.assertIsInstance(result, GncCommodity)
- found_one = True
- self.assertTrue(found_one,
- "No account returned a non-None commodity")
+ self.acct = Account(self.book)
+ self.acct.SetCommodity(self.usd)
+ root.append_child(self.acct)
+
+ def tearDown(self):
+ self.ses.end()
+
+ def test_get_currency_or_parent_returns_commodity(self):
+ result = self.acct.get_currency_or_parent()
+ self.assertIsNotNone(result)
+ self.assertIsInstance(result, GncCommodity)
# ---------------------------------------------------------------------------
@@ -284,7 +310,6 @@ class TestCommodityObtainTwin(TestCase):
"""Verify GncCommodity.obtain_twin(book) returns GncCommodity."""
def test_obtain_twin_same_book(self):
- from gnucash import Session, GncCommodity
ses = Session()
book = ses.get_book()
table = book.get_table()
@@ -303,8 +328,6 @@ class TestCommodityNamespaceDS(TestCase):
GncCommodityNamespace."""
def test_get_namespace_ds(self):
- from gnucash import Session
- from gnucash.gnucash_core import GncCommodityNamespace
ses = Session()
book = ses.get_book()
table = book.get_table()
@@ -318,37 +341,16 @@ class TestCommodityNamespaceDS(TestCase):
# ---------------------------------------------------------------------------
# Test: SWIG typemap compatibility (wrapper â instance unwrap)
# ---------------------------------------------------------------------------
- at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
-class TestSwigTypemapCompat(TestCase):
+class TestSwigTypemapCompat(PriceSession):
"""Verify that passing a ClassFromFunctions wrapper to a C function
still works (via the SWIG typemap) and emits a DeprecationWarning."""
- @classmethod
- def setUpClass(cls):
- if not _can_open_xml():
- raise unittest.SkipTest("XML backend not available")
- cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_swig_")
- from gnucash import Session, SessionOpenMode
- uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
- cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
- cls.book = cls.ses.get_book()
- cls.table = cls.book.get_table()
- cls.pricedb = cls.book.get_price_db()
- cls.usd = cls.table.lookup("CURRENCY", "USD")
- cls.corl = cls.table.lookup("NASDAQ", "CORL")
-
- @classmethod
- def tearDownClass(cls):
- cls.ses.end()
- shutil.rmtree(cls._tmpdir, ignore_errors=True)
-
def test_wrapper_triggers_deprecation_warning(self):
"""Passing a GncPrice wrapper to gnucash_core_c should emit
DeprecationWarning and still return a valid result."""
- from gnucash import GncPrice
from gnucash import gnucash_core_c as gc
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
if price is None:
self.skipTest("No price data")
@@ -358,7 +360,6 @@ class TestSwigTypemapCompat(TestCase):
warnings.simplefilter("always")
# Pass the wrapper object directly â typemap should unwrap it
comm_instance = gc.gnc_price_get_commodity(price)
- # Check that a DeprecationWarning was issued
dep_warnings = [x for x in w
if issubclass(x.category, DeprecationWarning)]
self.assertGreater(len(dep_warnings), 0,
@@ -368,7 +369,7 @@ class TestSwigTypemapCompat(TestCase):
"""Passing price.instance directly should NOT emit a warning."""
from gnucash import gnucash_core_c as gc
- price = self.pricedb.lookup_latest(self.corl, self.usd)
+ price = self.pricedb.lookup_latest(self.test_comm, self.usd)
if price is None:
self.skipTest("No price data")
@@ -384,54 +385,31 @@ class TestSwigTypemapCompat(TestCase):
# ---------------------------------------------------------------------------
# Test: GncPriceDB.get_*_price returns GncNumeric
# ---------------------------------------------------------------------------
- at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
-class TestGetPriceReturnsGncNumeric(TestCase):
+class TestGetPriceReturnsGncNumeric(PriceSession):
"""Verify that get_latest_price, get_nearest_price, and
get_nearest_before_price return GncNumeric instead of raw
_gnc_numeric."""
- @classmethod
- def setUpClass(cls):
- if not _can_open_xml():
- raise unittest.SkipTest("XML backend not available")
- cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_getprice_")
- from gnucash import Session, SessionOpenMode
- uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
- cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
- cls.book = cls.ses.get_book()
- cls.table = cls.book.get_table()
- cls.pricedb = cls.book.get_price_db()
- cls.usd = cls.table.lookup("CURRENCY", "USD")
- cls.corl = cls.table.lookup("NASDAQ", "CORL")
-
- @classmethod
- def tearDownClass(cls):
- cls.ses.end()
- shutil.rmtree(cls._tmpdir, ignore_errors=True)
-
def test_get_latest_price_returns_gnc_numeric(self):
- from gnucash import GncNumeric
- val = self.pricedb.get_latest_price(self.corl, self.usd)
+ val = self.pricedb.get_latest_price(self.test_comm, self.usd)
self.assertIsInstance(val, GncNumeric)
self.assertNotEqual(float(val), 0.0,
- "Expected a non-zero price for CORL/USD")
+ "Expected a non-zero price for TSTK/USD")
def test_get_nearest_price_returns_gnc_numeric(self):
- from gnucash import GncNumeric
- date = datetime(2001, 3, 26)
- val = self.pricedb.get_nearest_price(self.corl, self.usd, date)
+ date = datetime(2025, 1, 20)
+ val = self.pricedb.get_nearest_price(self.test_comm, self.usd, date)
self.assertIsInstance(val, GncNumeric)
def test_get_nearest_before_price_returns_gnc_numeric(self):
- from gnucash import GncNumeric
- date = datetime(2001, 4, 1)
- val = self.pricedb.get_nearest_before_price(self.corl, self.usd, date)
+ date = datetime(2025, 7, 1)
+ val = self.pricedb.get_nearest_before_price(
+ self.test_comm, self.usd, date)
self.assertIsInstance(val, GncNumeric)
def test_get_latest_price_arithmetic(self):
"""Verify the returned GncNumeric supports arithmetic."""
- from gnucash import GncNumeric
- val = self.pricedb.get_latest_price(self.corl, self.usd)
+ val = self.pricedb.get_latest_price(self.test_comm, self.usd)
doubled = val + val
self.assertIsInstance(doubled, GncNumeric)
self.assertAlmostEqual(float(doubled), float(val) * 2, places=6)
@@ -445,21 +423,18 @@ class TestDoubleWrapProtection(TestCase):
class constructor unwraps it instead of creating a broken object."""
def test_gnc_numeric_double_wrap(self):
- from gnucash import GncNumeric
original = GncNumeric(7, 3)
double = GncNumeric(instance=original)
self.assertEqual(double.num(), 7)
self.assertEqual(double.denom(), 3)
def test_gnc_numeric_double_wrap_arithmetic(self):
- from gnucash import GncNumeric
original = GncNumeric(1, 4)
double = GncNumeric(instance=original)
result = double + GncNumeric(3, 4)
self.assertAlmostEqual(float(result), 1.0, places=6)
def test_gnc_commodity_double_wrap(self):
- from gnucash import Session, GncCommodity
ses = Session()
book = ses.get_book()
table = book.get_table()
@@ -471,7 +446,6 @@ class TestDoubleWrapProtection(TestCase):
def test_raw_instance_still_works(self):
"""Passing a raw SWIG proxy as instance= must still work."""
- from gnucash import GncNumeric
from gnucash import gnucash_core_c as gc
raw = gc.gnc_numeric_create(5, 2)
val = GncNumeric(instance=raw)
@@ -480,5 +454,4 @@ class TestDoubleWrapProtection(TestCase):
if __name__ == '__main__':
- import unittest
main()
commit 556e7b0d70e4ff2e0414fb530c3df4236743a29c
Author: Noah R <Noerr at users.noreply.github.com>
Date: Fri Mar 6 14:18:25 2026 -0800
[python-bindings] Add get_*_price GncNumeric wrapping, double-wrap protection, and tests
Wrap GncPriceDB.get_latest_price, get_nearest_price, and
get_nearest_before_price to return GncNumeric instead of raw
_gnc_numeric SWIG proxies.
Add double-wrap safety in ClassFromFunctions.__init__: if a wrapper
object is passed as instance=, unwrap it to the underlying SWIG proxy.
This prevents breakage when callers re-wrap a return value that changed
from a raw SWIG proxy to a wrapper class, e.g.
GncNumeric(instance=pricedb.get_latest_price(...)).
New tests:
TestGetPriceReturnsGncNumeric (4 tests):
get_latest_price, get_nearest_price, get_nearest_before_price
return GncNumeric; arithmetic works on the result.
TestDoubleWrapProtection (4 tests):
GncNumeric, GncCommodity double-wrap; raw SWIG proxy still works.
diff --git a/bindings/python/function_class.py b/bindings/python/function_class.py
index 6bc954f1ba..d93d20e5e7 100644
--- a/bindings/python/function_class.py
+++ b/bindings/python/function_class.py
@@ -68,7 +68,14 @@ class ClassFromFunctions(object):
data. (by calling the .instance property)
"""
if INSTANCE_ARGUMENT in kargs and kargs[INSTANCE_ARGUMENT] is not None:
- self.__instance = kargs[INSTANCE_ARGUMENT]
+ inst = kargs[INSTANCE_ARGUMENT]
+ # Unwrap if someone passes a wrapper object as instance data,
+ # e.g. GncNumeric(instance=some_GncNumeric). This can happen
+ # when a method's return type is changed from a raw SWIG proxy
+ # to a wrapper class and callers still re-wrap the result.
+ if isinstance(inst, ClassFromFunctions):
+ inst = inst.instance
+ self.__instance = inst
else:
self.__instance = getattr(self._module, self._new_instance)(
*process_list_convert_to_instance(args),
diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py
index a758db584f..6bef3ed9b9 100644
--- a/bindings/python/gnucash_core.py
+++ b/bindings/python/gnucash_core.py
@@ -775,6 +775,9 @@ PriceDB_dict = {
'convert_balance_latest_price' : GncNumeric,
'convert_balance_nearest_price_t64' : GncNumeric,
'convert_balance_nearest_before_price_t64' : GncNumeric,
+ 'get_latest_price' : GncNumeric,
+ 'get_nearest_price' : GncNumeric,
+ 'get_nearest_before_price' : GncNumeric,
}
methods_return_instance(GncPriceDB,PriceDB_dict)
methods_return_instance_lists(
diff --git a/bindings/python/tests/test_price_and_wrapping.py b/bindings/python/tests/test_price_and_wrapping.py
index 50d008416b..9c46b33958 100644
--- a/bindings/python/tests/test_price_and_wrapping.py
+++ b/bindings/python/tests/test_price_and_wrapping.py
@@ -381,6 +381,104 @@ class TestSwigTypemapCompat(TestCase):
"Unexpected DeprecationWarning for .instance")
+# ---------------------------------------------------------------------------
+# Test: GncPriceDB.get_*_price returns GncNumeric
+# ---------------------------------------------------------------------------
+ at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
+class TestGetPriceReturnsGncNumeric(TestCase):
+ """Verify that get_latest_price, get_nearest_price, and
+ get_nearest_before_price return GncNumeric instead of raw
+ _gnc_numeric."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not _can_open_xml():
+ raise unittest.SkipTest("XML backend not available")
+ cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_getprice_")
+ from gnucash import Session, SessionOpenMode
+ uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
+ cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
+ cls.book = cls.ses.get_book()
+ cls.table = cls.book.get_table()
+ cls.pricedb = cls.book.get_price_db()
+ cls.usd = cls.table.lookup("CURRENCY", "USD")
+ cls.corl = cls.table.lookup("NASDAQ", "CORL")
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.ses.end()
+ shutil.rmtree(cls._tmpdir, ignore_errors=True)
+
+ def test_get_latest_price_returns_gnc_numeric(self):
+ from gnucash import GncNumeric
+ val = self.pricedb.get_latest_price(self.corl, self.usd)
+ self.assertIsInstance(val, GncNumeric)
+ self.assertNotEqual(float(val), 0.0,
+ "Expected a non-zero price for CORL/USD")
+
+ def test_get_nearest_price_returns_gnc_numeric(self):
+ from gnucash import GncNumeric
+ date = datetime(2001, 3, 26)
+ val = self.pricedb.get_nearest_price(self.corl, self.usd, date)
+ self.assertIsInstance(val, GncNumeric)
+
+ def test_get_nearest_before_price_returns_gnc_numeric(self):
+ from gnucash import GncNumeric
+ date = datetime(2001, 4, 1)
+ val = self.pricedb.get_nearest_before_price(self.corl, self.usd, date)
+ self.assertIsInstance(val, GncNumeric)
+
+ def test_get_latest_price_arithmetic(self):
+ """Verify the returned GncNumeric supports arithmetic."""
+ from gnucash import GncNumeric
+ val = self.pricedb.get_latest_price(self.corl, self.usd)
+ doubled = val + val
+ self.assertIsInstance(doubled, GncNumeric)
+ self.assertAlmostEqual(float(doubled), float(val) * 2, places=6)
+
+
+# ---------------------------------------------------------------------------
+# Test: ClassFromFunctions double-wrap protection
+# ---------------------------------------------------------------------------
+class TestDoubleWrapProtection(TestCase):
+ """Verify that passing a wrapper object as instance= to a wrapper
+ class constructor unwraps it instead of creating a broken object."""
+
+ def test_gnc_numeric_double_wrap(self):
+ from gnucash import GncNumeric
+ original = GncNumeric(7, 3)
+ double = GncNumeric(instance=original)
+ self.assertEqual(double.num(), 7)
+ self.assertEqual(double.denom(), 3)
+
+ def test_gnc_numeric_double_wrap_arithmetic(self):
+ from gnucash import GncNumeric
+ original = GncNumeric(1, 4)
+ double = GncNumeric(instance=original)
+ result = double + GncNumeric(3, 4)
+ self.assertAlmostEqual(float(result), 1.0, places=6)
+
+ def test_gnc_commodity_double_wrap(self):
+ from gnucash import Session, GncCommodity
+ ses = Session()
+ book = ses.get_book()
+ table = book.get_table()
+ usd = table.lookup("CURRENCY", "USD")
+ double = GncCommodity(instance=usd)
+ self.assertIsInstance(double, GncCommodity)
+ self.assertEqual(double.get_mnemonic(), "USD")
+ ses.end()
+
+ def test_raw_instance_still_works(self):
+ """Passing a raw SWIG proxy as instance= must still work."""
+ from gnucash import GncNumeric
+ from gnucash import gnucash_core_c as gc
+ raw = gc.gnc_numeric_create(5, 2)
+ val = GncNumeric(instance=raw)
+ self.assertEqual(val.num(), 5)
+ self.assertEqual(val.denom(), 2)
+
+
if __name__ == '__main__':
import unittest
main()
commit 592b57ddbb3917399e2b308ed3e8d5108610589e
Author: Noah R <Noerr at users.noreply.github.com>
Date: Fri Mar 6 12:53:56 2026 -0800
[python-bindings] Fix missing return-type wrapping and clean up examples
Many Python binding methods silently returned raw SwigPyObject pointers
instead of proper Python wrapper objects because the methods_return_instance
dicts were incomplete. This adds the missing entries and activates the
DeprecationWarning in the SWIG typemap fallback path from PR 1.
Return-type wrapping fixes (gnucash_core.py):
GncPrice (new dict â no wrapping existed before):
get_commodity â GncCommodity, get_currency â GncCommodity,
clone â GncPrice, get_value â GncNumeric
GncPriceDB (added to existing dict):
nth_price â GncPrice, lookup_day_t64 â GncPrice,
convert_balance_nearest_before_price_t64 â GncNumeric
GncPriceDB (new list wrapping):
lookup_latest_any_currency â list[GncPrice],
lookup_nearest_before_any_currency_t64 â list[GncPrice],
lookup_nearest_in_time_any_currency_t64 â list[GncPrice]
GncCommodity: obtain_twin â GncCommodity,
get_namespace_ds â GncCommodityNamespace
Account: get_currency_or_parent â GncCommodity,
GetLotList â list[GncLot]
GncLot: get_split_list â list[Split]
Example script cleanup:
Remove type(x).__name__ == 'SwigPyObject' workarounds from
gnc_convenience.py, gncinvoicefkt.py, str_methods.py â these are
no longer needed now that methods return proper wrapper objects.
SWIG typemap (gnucash_core.i):
Activate DeprecationWarning on the .instance fallback path, replacing
the TODO placeholder from the typemap compatibility layer commit.
New test file (test_price_and_wrapping.py):
18 tests covering all wrapping fixes and typemap compatibility,
using in-repo test data (pricedb1.gml2, sample1.gnucash).
Tests auto-skip when XML backend is unavailable.
diff --git a/bindings/python/example_scripts/gnc_convenience.py b/bindings/python/example_scripts/gnc_convenience.py
index 2d222379a1..700654bc48 100644
--- a/bindings/python/example_scripts/gnc_convenience.py
+++ b/bindings/python/example_scripts/gnc_convenience.py
@@ -9,7 +9,6 @@
#
from gnucash import Session, Account, Transaction, Split
-import gnucash
def get_transaction_list(account):
@@ -26,8 +25,6 @@ def get_transaction_list(account):
split_list=account.GetSplitList()
transaction_list=[]
for split in split_list:
- if type(split) != Split:
- split = Split(instance=split)
transaction=split.GetParent()
if not (transaction in transaction_list): # this check may not be necessary.
transaction_list.append(transaction)
@@ -53,8 +50,6 @@ def get_splits_without_lot(account=None,split_list=None):
rlist=[]
for split in split_list:
- if type(split).__name__ == 'SwigPyObject':
- split = Split(instance=split)
lot=split.GetLot()
if lot == None:
rlist.append(split)
@@ -78,8 +73,6 @@ def find_account(account,name,account_list=None):
account_list=[]
for child in account.get_children():
- if type(child) != Account:
- child=Account(instance=child)
account_list=find_account(child,name,account_list)
account_name=account.GetName()
@@ -103,8 +96,6 @@ def find_lot(lot_list,search_string):
rlist=[]
for lot in lot_list:
- if type(lot).__name__ == 'SwigPyObject':
- lot = gnucash.GncLot(instance=lot)
ltitle=lot.get_title()
if search_string in ltitle:
rlist.append(lot)
@@ -153,19 +144,11 @@ def find_split_recursive(account, search_string):
# Get all splits in descendants
for child in account.get_children():
- if type(child) != Account:
- child = Account(instance=child)
childsplits = find_split_recursive(child, search_string)
- for split in childsplits:
- if type(split) != Split:
- split = Split(instance=split)
child_account_splits += childsplits
# Get all splits in account
splits=account.GetSplitList()
- for split in splits:
- if type(split) != Split:
- split = Split(instance=split)
basic_account_splits=find_split(splits,search_string)
rlist=child_account_splits+basic_account_splits
@@ -206,9 +189,6 @@ def find_transaction(account,name,ignore_case=True,transaction_list=None):
sl=transaction.GetSplitList()
for split in sl:
- if type(split) != Split:
- split=Split(instance=split)
-
memo = split.GetMemo()
if ignore_case:
memo=memo.lower()
diff --git a/bindings/python/example_scripts/gncinvoicefkt.py b/bindings/python/example_scripts/gncinvoicefkt.py
index 28efed5a30..b2c31a594c 100644
--- a/bindings/python/example_scripts/gncinvoicefkt.py
+++ b/bindings/python/example_scripts/gncinvoicefkt.py
@@ -31,8 +31,6 @@ def get_all_lots(account):
ltotal=[]
descs = account.get_descendants()
for desc in descs:
- if type(desc).__name__ == 'SwigPyObject':
- desc = gnucash.Account(instance=desc)
ll=desc.GetLotList()
ltotal+=ll
return ltotal
@@ -45,9 +43,6 @@ def get_all_invoices_from_lots(account):
lot_list=get_all_lots(account)
invoice_list=[]
for lot in lot_list:
- if type(lot).__name__ == 'SwigPyObject':
- lot = gnucash.GncLot(instance=lot)
-
invoice=gnucash.gnucash_core_c.gncInvoiceGetInvoiceFromLot(lot.instance)
if invoice:
invoice_list.append(Invoice(instance=invoice))
diff --git a/bindings/python/example_scripts/str_methods.py b/bindings/python/example_scripts/str_methods.py
index dfbec36c66..abc6e1899e 100644
--- a/bindings/python/example_scripts/str_methods.py
+++ b/bindings/python/example_scripts/str_methods.py
@@ -214,8 +214,6 @@ def __split__str__(self, encoding=None, error=None):
lot=self.GetLot()
if lot:
- if type(lot).__name__ == 'SwigPyObject':
- lot=gnucash.GncLot(instance=lot)
lot_str=lot.get_title()
else:
lot_str='---'
diff --git a/bindings/python/gnucash_core.i b/bindings/python/gnucash_core.i
index 1dbef8413d..0eb5af076f 100644
--- a/bindings/python/gnucash_core.i
+++ b/bindings/python/gnucash_core.i
@@ -116,8 +116,12 @@
res = SWIG_ConvertPtr(instance, &argp, $1_descriptor, 0);
Py_DECREF(instance);
if (SWIG_IsOK(res)) {
- /* TODO: Add DeprecationWarning once return-type
- * wrapping is fixed (PR 2). */
+ if (PyErr_WarnEx(PyExc_DeprecationWarning,
+ "Passing " #CType " wrapper objects directly to "
+ "gnucash_core_c is deprecated; "
+ "use the Python API or .instance attribute.", 1) < 0) {
+ SWIG_fail;
+ }
$1 = %reinterpret_cast(argp, $1_ltype);
} else {
SWIG_exception_fail(SWIG_TypeError,
diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py
index 6ee86786f9..a758db584f 100644
--- a/bindings/python/gnucash_core.py
+++ b/bindings/python/gnucash_core.py
@@ -770,12 +770,19 @@ PriceDB_dict = {
'lookup_latest' : GncPrice,
'lookup_nearest_in_time64' : GncPrice,
'lookup_nearest_before_t64' : GncPrice,
+ 'nth_price' : GncPrice,
+ 'lookup_day_t64' : GncPrice,
'convert_balance_latest_price' : GncNumeric,
'convert_balance_nearest_price_t64' : GncNumeric,
+ 'convert_balance_nearest_before_price_t64' : GncNumeric,
}
methods_return_instance(GncPriceDB,PriceDB_dict)
-GncPriceDB.get_prices = method_function_returns_instance_list(
- GncPriceDB.get_prices, GncPrice )
+methods_return_instance_lists(
+ GncPriceDB, { 'get_prices': GncPrice,
+ 'lookup_latest_any_currency': GncPrice,
+ 'lookup_nearest_before_any_currency_t64': GncPrice,
+ 'lookup_nearest_in_time_any_currency_t64': GncPrice,
+ })
class GncCommodity(GnuCashCoreClass): pass
@@ -975,9 +982,21 @@ methods_return_instance(GncNumeric, gncnumeric_dict)
# GncCommodity
GncCommodity.add_constructor_and_methods_with_prefix('gnc_commodity_', 'new')
-#Functions that return GncCommodity
-GncCommodity.clone = method_function_returns_instance(
- GncCommodity.clone, GncCommodity )
+gnc_commodity_dict = {
+ 'clone': GncCommodity,
+ 'obtain_twin': GncCommodity,
+ 'get_namespace_ds': GncCommodityNamespace,
+ }
+methods_return_instance(GncCommodity, gnc_commodity_dict)
+
+# GncPrice (deferred until after GncCommodity is defined)
+gnc_price_dict = {
+ 'get_commodity': GncCommodity,
+ 'get_currency': GncCommodity,
+ 'clone': GncPrice,
+ 'get_value': GncNumeric,
+ }
+methods_return_instance(GncPrice, gnc_price_dict)
# GncCommodityTable
GncCommodityTable.add_methods_with_prefix('gnc_commodity_table_')
@@ -1018,6 +1037,9 @@ gnclot_dict = {
'make_default' : GncLot
}
methods_return_instance(GncLot, gnclot_dict)
+methods_return_instance_lists(
+ GncLot, { 'get_split_list': Split,
+ })
# Transaction
Transaction.add_methods_with_prefix('xaccTrans')
@@ -1109,11 +1131,13 @@ account_dict = {
'GetBalanceAsOfDateInCurrency' : GncNumeric,
'GetBalanceChangeForPeriod' : GncNumeric,
'GetCommodity' : GncCommodity,
+ 'get_currency_or_parent' : GncCommodity,
'GetGUID': GUID
}
methods_return_instance(Account, account_dict)
methods_return_instance_lists(
Account, { 'GetSplitList': Split,
+ 'GetLotList': GncLot,
'get_children': Account,
'get_children_sorted': Account,
'get_descendants': Account,
diff --git a/bindings/python/tests/test_price_and_wrapping.py b/bindings/python/tests/test_price_and_wrapping.py
new file mode 100644
index 0000000000..50d008416b
--- /dev/null
+++ b/bindings/python/tests/test_price_and_wrapping.py
@@ -0,0 +1,386 @@
+"""Tests for GncPrice / GncPriceDB / GncLot return-type wrapping.
+
+These tests require an XML backend (i.e. build with -DWITH_GNUCASH=ON or at
+least with the XML backend enabled) because they open on-disk GnuCash files.
+
+Test data comes from files already in the repo:
+ - pricedb1.gml2 : 13 commodities, many prices in USD, 1 root account
+ - sample1.gnucash : accounts, transactions, splits, 1 lot
+"""
+
+import os
+import shutil
+import tempfile
+import warnings
+from datetime import datetime
+from pathlib import Path
+from unittest import TestCase, main, skipUnless
+from urllib.parse import urlunparse
+
+# Locate test data relative to the source tree. When running from a build
+# directory the source tree is typically the parent; we walk up until we find
+# the marker directory.
+def _find_source_root():
+ """Walk up from this file looking for the repo root."""
+ d = Path(__file__).resolve().parent
+ for _ in range(10):
+ if (d / "libgnucash").is_dir():
+ return d
+ d = d.parent
+ # Fall back to environment variable set by CMake / CTest
+ builddir = os.environ.get("GNC_BUILDDIR")
+ if builddir:
+ # source tree is often one level up from build dir
+ candidate = Path(builddir).parent
+ if (candidate / "libgnucash").is_dir():
+ return candidate
+ return None
+
+_SRC_ROOT = _find_source_root()
+_PRICEDB_FILE = (
+ _SRC_ROOT / "libgnucash" / "backend" / "xml" / "test" / "test-files"
+ / "xml2" / "pricedb1.gml2"
+) if _SRC_ROOT else None
+_SAMPLE_FILE = (
+ _SRC_ROOT / "libgnucash" / "backend" / "xml" / "test" / "test-files"
+ / "load-save" / "sample1.gnucash"
+) if _SRC_ROOT else None
+
+_HAS_TEST_DATA = _PRICEDB_FILE is not None and _PRICEDB_FILE.exists()
+_HAS_SAMPLE_DATA = _SAMPLE_FILE is not None and _SAMPLE_FILE.exists()
+
+
+def _copy_to_tmp(src_path, tmpdir):
+ """Copy a GnuCash file into a temp dir and return an xml:// URI."""
+ fname = os.path.basename(src_path)
+ dest = os.path.join(tmpdir, fname)
+ shutil.copy2(str(src_path), dest)
+ # URI format: xml://<dir>/<filename> (matches test_session.py convention)
+ return urlunparse(("xml", tmpdir, fname, "", "", ""))
+
+
+def _can_open_xml():
+ """Return True if the XML backend is available."""
+ try:
+ from gnucash import Session, SessionOpenMode
+ with tempfile.TemporaryDirectory() as tmpdir:
+ uri = urlunparse(("xml", tmpdir, "probe", "", "", ""))
+ with Session(uri, SessionOpenMode.SESSION_NEW_STORE) as ses:
+ pass
+ return True
+ except Exception:
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Test: GncPrice and GncPriceDB wrapping via pricedb1.gml2
+# ---------------------------------------------------------------------------
+ at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
+class TestGncPriceWrapping(TestCase):
+ """Open pricedb1.gml2 and verify that GncPrice / GncPriceDB methods
+ return properly wrapped Python objects instead of raw SwigPyObjects."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not _can_open_xml():
+ raise unittest.SkipTest("XML backend not available")
+ cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_price_")
+ from gnucash import Session, SessionOpenMode
+ uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
+ cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
+ cls.book = cls.ses.get_book()
+ cls.table = cls.book.get_table()
+ cls.pricedb = cls.book.get_price_db()
+ # Look up a commodity we know is in the file
+ cls.usd = cls.table.lookup("CURRENCY", "USD")
+ cls.corl = cls.table.lookup("NASDAQ", "CORL")
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.ses.end()
+ shutil.rmtree(cls._tmpdir, ignore_errors=True)
+
+ # -- basic sanity --
+
+ def test_commodity_lookup(self):
+ """Verify we can find the commodities in the test file."""
+ self.assertIsNotNone(self.usd)
+ self.assertIsNotNone(self.corl)
+
+ # -- GncPriceDB single-price lookups --
+
+ def test_lookup_latest_returns_gnc_price(self):
+ from gnucash import GncPrice
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ self.assertIsNotNone(price, "No price found for CORL/USD")
+ self.assertIsInstance(price, GncPrice)
+
+ def test_nth_price_returns_gnc_price(self):
+ from gnucash import GncPrice
+ price = self.pricedb.nth_price(self.corl, 0)
+ self.assertIsNotNone(price, "nth_price(CORL, 0) returned None")
+ self.assertIsInstance(price, GncPrice)
+
+ # -- GncPrice attribute methods --
+
+ def test_get_commodity_returns_gnc_commodity(self):
+ from gnucash import GncPrice, GncCommodity
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ self.assertIsNotNone(price)
+ comm = price.get_commodity()
+ self.assertIsInstance(comm, GncCommodity)
+
+ def test_get_currency_returns_gnc_commodity(self):
+ from gnucash import GncPrice, GncCommodity
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ self.assertIsNotNone(price)
+ curr = price.get_currency()
+ self.assertIsInstance(curr, GncCommodity)
+
+ def test_get_value_returns_gnc_numeric(self):
+ from gnucash import GncPrice, GncNumeric
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ self.assertIsNotNone(price)
+ val = price.get_value()
+ self.assertIsInstance(val, GncNumeric)
+
+ def test_clone_returns_gnc_price(self):
+ from gnucash import GncPrice
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ self.assertIsNotNone(price)
+ cloned = price.clone(self.book)
+ self.assertIsInstance(cloned, GncPrice)
+
+ # -- GncPriceDB list methods --
+
+ def test_lookup_latest_any_currency_returns_list_of_gnc_price(self):
+ from gnucash import GncPrice
+ prices = self.pricedb.lookup_latest_any_currency(self.corl)
+ self.assertIsInstance(prices, list)
+ # pricedb1.gml2 has CORL priced in USD so we expect at least 1
+ self.assertGreater(len(prices), 0,
+ "Expected at least one price for CORL")
+ for p in prices:
+ self.assertIsInstance(p, GncPrice)
+
+ def test_get_prices_returns_list_of_gnc_price(self):
+ from gnucash import GncPrice
+ prices = self.pricedb.get_prices(self.corl, self.usd)
+ self.assertIsInstance(prices, list)
+ self.assertGreater(len(prices), 0)
+ for p in prices:
+ self.assertIsInstance(p, GncPrice)
+
+ def test_lookup_nearest_in_time_any_currency(self):
+ from gnucash import GncPrice
+ # Prices in pricedb1 are from 2001-03-26
+ date = datetime(2001, 3, 26)
+ prices = self.pricedb.lookup_nearest_in_time_any_currency_t64(
+ self.corl, date)
+ self.assertIsInstance(prices, list)
+ for p in prices:
+ self.assertIsInstance(p, GncPrice)
+
+ def test_lookup_nearest_before_any_currency(self):
+ from gnucash import GncPrice
+ date = datetime(2001, 4, 1)
+ prices = self.pricedb.lookup_nearest_before_any_currency_t64(
+ self.corl, date)
+ self.assertIsInstance(prices, list)
+ for p in prices:
+ self.assertIsInstance(p, GncPrice)
+
+
+# ---------------------------------------------------------------------------
+# Test: GncLot.get_split_list via sample1.gnucash
+# ---------------------------------------------------------------------------
+ at skipUnless(_HAS_SAMPLE_DATA, "sample1.gnucash not found in source tree")
+class TestGncLotSplitList(TestCase):
+ """Open sample1.gnucash and verify that GncLot.get_split_list() returns
+ properly wrapped Split objects."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not _can_open_xml():
+ raise unittest.SkipTest("XML backend not available")
+ cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_lot_")
+ from gnucash import Session, SessionOpenMode
+ uri = _copy_to_tmp(_SAMPLE_FILE, cls._tmpdir)
+ cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
+ cls.book = cls.ses.get_book()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.ses.end()
+ shutil.rmtree(cls._tmpdir, ignore_errors=True)
+
+ def _find_lot(self):
+ """Find the first lot in any account."""
+ root = self.book.get_root_account()
+ for acct in root.get_descendants():
+ lots = acct.GetLotList()
+ if lots:
+ return lots[0]
+ return None
+
+ def test_lot_exists(self):
+ """sample1.gnucash should have at least one lot."""
+ lot = self._find_lot()
+ self.assertIsNotNone(lot, "No lots found in sample1.gnucash")
+
+ def test_get_split_list_returns_splits(self):
+ from gnucash import Split
+ lot = self._find_lot()
+ if lot is None:
+ self.skipTest("No lots in sample1.gnucash")
+ splits = lot.get_split_list()
+ self.assertIsInstance(splits, list)
+ self.assertGreater(len(splits), 0, "Lot has no splits")
+ for s in splits:
+ self.assertIsInstance(s, Split)
+
+
+# ---------------------------------------------------------------------------
+# Test: Account.get_currency_or_parent via sample1.gnucash
+# ---------------------------------------------------------------------------
+ at skipUnless(_HAS_SAMPLE_DATA, "sample1.gnucash not found in source tree")
+class TestAccountCurrencyOrParent(TestCase):
+ """Verify Account.get_currency_or_parent() returns GncCommodity."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not _can_open_xml():
+ raise unittest.SkipTest("XML backend not available")
+ cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_acct_")
+ from gnucash import Session, SessionOpenMode
+ uri = _copy_to_tmp(_SAMPLE_FILE, cls._tmpdir)
+ cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
+ cls.book = cls.ses.get_book()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.ses.end()
+ shutil.rmtree(cls._tmpdir, ignore_errors=True)
+
+ def test_get_currency_or_parent_returns_commodity(self):
+ from gnucash import GncCommodity
+ root = self.book.get_root_account()
+ descendants = root.get_descendants()
+ self.assertGreater(len(descendants), 0)
+ found_one = False
+ for acct in descendants:
+ result = acct.get_currency_or_parent()
+ if result is not None:
+ self.assertIsInstance(result, GncCommodity)
+ found_one = True
+ self.assertTrue(found_one,
+ "No account returned a non-None commodity")
+
+
+# ---------------------------------------------------------------------------
+# Test: GncCommodity.obtain_twin wrapping
+# ---------------------------------------------------------------------------
+class TestCommodityObtainTwin(TestCase):
+ """Verify GncCommodity.obtain_twin(book) returns GncCommodity."""
+
+ def test_obtain_twin_same_book(self):
+ from gnucash import Session, GncCommodity
+ ses = Session()
+ book = ses.get_book()
+ table = book.get_table()
+ usd = table.lookup("CURRENCY", "USD")
+ self.assertIsNotNone(usd)
+ twin = usd.obtain_twin(book)
+ self.assertIsInstance(twin, GncCommodity)
+ ses.end()
+
+
+# ---------------------------------------------------------------------------
+# Test: GncCommodity.get_namespace_ds wrapping
+# ---------------------------------------------------------------------------
+class TestCommodityNamespaceDS(TestCase):
+ """Verify GncCommodity.get_namespace_ds() returns
+ GncCommodityNamespace."""
+
+ def test_get_namespace_ds(self):
+ from gnucash import Session
+ from gnucash.gnucash_core import GncCommodityNamespace
+ ses = Session()
+ book = ses.get_book()
+ table = book.get_table()
+ usd = table.lookup("CURRENCY", "USD")
+ self.assertIsNotNone(usd)
+ ns = usd.get_namespace_ds()
+ self.assertIsInstance(ns, GncCommodityNamespace)
+ ses.end()
+
+
+# ---------------------------------------------------------------------------
+# Test: SWIG typemap compatibility (wrapper â instance unwrap)
+# ---------------------------------------------------------------------------
+ at skipUnless(_HAS_TEST_DATA, "pricedb1.gml2 not found in source tree")
+class TestSwigTypemapCompat(TestCase):
+ """Verify that passing a ClassFromFunctions wrapper to a C function
+ still works (via the SWIG typemap) and emits a DeprecationWarning."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not _can_open_xml():
+ raise unittest.SkipTest("XML backend not available")
+ cls._tmpdir = tempfile.mkdtemp(prefix="gnc_test_swig_")
+ from gnucash import Session, SessionOpenMode
+ uri = _copy_to_tmp(_PRICEDB_FILE, cls._tmpdir)
+ cls.ses = Session(uri, SessionOpenMode.SESSION_NORMAL_OPEN)
+ cls.book = cls.ses.get_book()
+ cls.table = cls.book.get_table()
+ cls.pricedb = cls.book.get_price_db()
+ cls.usd = cls.table.lookup("CURRENCY", "USD")
+ cls.corl = cls.table.lookup("NASDAQ", "CORL")
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.ses.end()
+ shutil.rmtree(cls._tmpdir, ignore_errors=True)
+
+ def test_wrapper_triggers_deprecation_warning(self):
+ """Passing a GncPrice wrapper to gnucash_core_c should emit
+ DeprecationWarning and still return a valid result."""
+ from gnucash import GncPrice
+ from gnucash import gnucash_core_c as gc
+
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ if price is None:
+ self.skipTest("No price data")
+
+ self.assertIsInstance(price, GncPrice)
+
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ # Pass the wrapper object directly â typemap should unwrap it
+ comm_instance = gc.gnc_price_get_commodity(price)
+ # Check that a DeprecationWarning was issued
+ dep_warnings = [x for x in w
+ if issubclass(x.category, DeprecationWarning)]
+ self.assertGreater(len(dep_warnings), 0,
+ "Expected DeprecationWarning from typemap")
+
+ def test_raw_instance_no_warning(self):
+ """Passing price.instance directly should NOT emit a warning."""
+ from gnucash import gnucash_core_c as gc
+
+ price = self.pricedb.lookup_latest(self.corl, self.usd)
+ if price is None:
+ self.skipTest("No price data")
+
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ comm_instance = gc.gnc_price_get_commodity(price.instance)
+ dep_warnings = [x for x in w
+ if issubclass(x.category, DeprecationWarning)]
+ self.assertEqual(len(dep_warnings), 0,
+ "Unexpected DeprecationWarning for .instance")
+
+
+if __name__ == '__main__':
+ import unittest
+ main()
commit e4ec72c97c80559db9ef4aa7d07eea37a9021dd5
Author: Noah R <Noerr at users.noreply.github.com>
Date: Fri Mar 6 12:35:35 2026 -0800
[python-bindings] Add SWIG typemap compatibility layer for wrapper objects
Add GNC_ACCEPT_WRAPPER macro that generates %typemap(in) entries for
all core engine and business pointer types. These typemaps accept both
raw SWIG pointers (zero-overhead fast path) and ClassFromFunctions
wrapper objects (fallback path that extracts .instance).
This is pure infrastructure for an upcoming change that fixes missing
return-type wrapping in gnucash_core.py. Once methods like
GncPriceDB.nth_price() return proper GncPrice wrapper objects instead
of raw SwigPyObjects, existing code that passes those objects to
gnucash_core_c C functions would break. These typemaps prevent that
breakage: the C functions transparently unwrap the .instance pointer.
Covered types:
Core: Account, Split, Transaction, GNCLot, gnc_commodity,
gnc_commodity_namespace, gnc_commodity_table, GNCPrice,
GNCPriceDB, QofBook, QofSession, GncGUID
Business: GncCustomer, GncEmployee, GncVendor, GncJob,
GncAddress, GncBillTerm, GncTaxTable, GncInvoice, GncEntry
GncOwner is excluded â it has its own custom type-dispatching typemaps.
diff --git a/bindings/python/gnucash_core.i b/bindings/python/gnucash_core.i
index 667b2a8c36..1dbef8413d 100644
--- a/bindings/python/gnucash_core.i
+++ b/bindings/python/gnucash_core.i
@@ -88,6 +88,78 @@
%include <base-typemaps.i>
+/* GNC_ACCEPT_WRAPPER: Generate input typemaps that accept both raw SWIG
+ * pointers and Python wrapper objects (ClassFromFunctions subclasses).
+ *
+ * The Python bindings use a two-layer architecture:
+ * - gnucash_core_c (SWIG-generated): exposes C functions with raw pointers
+ * - gnucash_core.py: wraps selected methods to return Python objects
+ *
+ * When gnucash_core.py wraps return types (via methods_return_instance),
+ * the returned Python objects store the raw SWIG pointer in a .instance
+ * attribute. Without these typemaps, passing such a wrapper object to a
+ * gnucash_core_c function fails because SWIG only recognizes its own
+ * pointer wrappers.
+ *
+ * These typemaps fix that: they try normal SWIG conversion first (zero
+ * overhead for the common case), and fall back to extracting .instance
+ * if needed.
+ */
+%define GNC_ACCEPT_WRAPPER(CType)
+%typemap(in) CType * (void *argp = NULL) {
+ int res = SWIG_ConvertPtr($input, &argp, $1_descriptor, 0);
+ if (SWIG_IsOK(res)) {
+ $1 = %reinterpret_cast(argp, $1_ltype);
+ } else {
+ PyObject *instance = PyObject_GetAttrString($input, "instance");
+ if (instance != NULL) {
+ res = SWIG_ConvertPtr(instance, &argp, $1_descriptor, 0);
+ Py_DECREF(instance);
+ if (SWIG_IsOK(res)) {
+ /* TODO: Add DeprecationWarning once return-type
+ * wrapping is fixed (PR 2). */
+ $1 = %reinterpret_cast(argp, $1_ltype);
+ } else {
+ SWIG_exception_fail(SWIG_TypeError,
+ "in method '$symname', argument $argnum:"
+ " .instance is not a " #CType " *");
+ }
+ } else {
+ PyErr_Clear();
+ SWIG_exception_fail(SWIG_TypeError,
+ "in method '$symname', argument $argnum:"
+ " expected " #CType " * or wrapper object");
+ }
+ }
+}
+%apply CType * { const CType * };
+%enddef
+
+/* Core engine types */
+GNC_ACCEPT_WRAPPER(Account)
+GNC_ACCEPT_WRAPPER(Split)
+GNC_ACCEPT_WRAPPER(Transaction)
+GNC_ACCEPT_WRAPPER(GNCLot)
+GNC_ACCEPT_WRAPPER(gnc_commodity)
+GNC_ACCEPT_WRAPPER(gnc_commodity_namespace)
+GNC_ACCEPT_WRAPPER(gnc_commodity_table)
+GNC_ACCEPT_WRAPPER(GNCPrice)
+GNC_ACCEPT_WRAPPER(GNCPriceDB)
+GNC_ACCEPT_WRAPPER(QofBook)
+GNC_ACCEPT_WRAPPER(QofSession)
+GNC_ACCEPT_WRAPPER(GncGUID)
+
+/* Business types */
+GNC_ACCEPT_WRAPPER(GncCustomer)
+GNC_ACCEPT_WRAPPER(GncEmployee)
+GNC_ACCEPT_WRAPPER(GncVendor)
+GNC_ACCEPT_WRAPPER(GncJob)
+GNC_ACCEPT_WRAPPER(GncAddress)
+GNC_ACCEPT_WRAPPER(GncBillTerm)
+GNC_ACCEPT_WRAPPER(GncTaxTable)
+GNC_ACCEPT_WRAPPER(GncInvoice)
+GNC_ACCEPT_WRAPPER(GncEntry)
+
%include <engine-common.i>
%include <qofbackend.h>
Summary of changes:
bindings/python/example_scripts/gnc_convenience.py | 20 -
bindings/python/example_scripts/gncinvoicefkt.py | 5 -
bindings/python/example_scripts/str_methods.py | 2 -
bindings/python/function_class.py | 9 +-
bindings/python/gnucash_core.i | 76 ++++
bindings/python/gnucash_core.py | 39 +-
bindings/python/tests/CMakeLists.txt | 3 +-
bindings/python/tests/runTests.py.in | 11 +
bindings/python/tests/test_price_and_wrapping.py | 457 +++++++++++++++++++++
9 files changed, 588 insertions(+), 34 deletions(-)
create mode 100644 bindings/python/tests/test_price_and_wrapping.py
More information about the gnucash-changes
mailing list