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