gnucash stable: Multiple changes pushed

John Ralls jralls at code.gnucash.org
Fri Sep 25 17:38:55 EDT 2026


Updated	 via  https://github.com/Gnucash/gnucash/commit/fbcb9f5e (commit)
	 via  https://github.com/Gnucash/gnucash/commit/89448ffd (commit)
	from  https://github.com/Gnucash/gnucash/commit/973a64ce (commit)



commit fbcb9f5e756321a161a1ecf478a81d866b1d03a8
Merge: 973a64ceda 89448ffd34
Author: John Ralls <jralls at ceridwen.us>
Date:   Fri Sep 25 14:38:24 2026 -0700

    Merge Noah Noerr's 'bug799796-price-example-getvalue' into stable.


commit 89448ffd3420ae563a4fece75b9e6ec9a9db5927
Author: Noerr <Noerr at users.noreply.github.com>
Date:   Wed Sep 23 01:05:21 2026 -0700

    [bindings/python] Fix and modernize the price example scripts (Bug 799796)
    
    PR #2187 made GncPrice.get_value() return a GncNumeric wrapper and clone() a
    fully wrapped GncPrice. The price example scripts still used the pre-5.15 raw
    _gnc_numeric idiom and broke -- reported as Bug 799796 (cloned prices silently
    kept the original value).
    
    Example scripts (bindings/python/example_scripts/):
    
    * quotes_historic.py -- reworked to the modern idiom; it now actually runs on
      5.x (it had further latent breakage beyond the reported one):
      - Construct each price directly with GncPrice(book) + setters instead of
        cloning an existing price, so the script no longer needs a pre-existing
        price in the book. Look the pricing currency (USD) up from the commodity
        table rather than deriving it from a split.
      - Build the value with the double->GncNumeric constructor at GnuCash's
        standard price precision -- the pricing currency's SCU * COMMODITY_DENOM_MULT
        rounded HALF_UP (gnc-pricedb.h "Price policy") -- dropping the
        Fraction.from_float().limit_denominator(100000) dance and its import.
      - Set the source with the PriceSource enum -- set_source(PriceSource.FINANCE_QUOTE)
        (the enum added in #2317). The original set_source("Finance::Quotes::Historic")
        passed a string to what has been an enum setter since 2.6, which raises
        TypeError on modern bindings.
      - Open with Session(url), which defaults to SESSION_NORMAL_OPEN. The old
        Session(url, True, False, False) passed the deprecated ignore_lock=True,
        silently bypassing the book's lock file.
    
    * priceDB_test.py, price_database_example.py -- num/denom are methods now, so
      read v.num() / v.denom().
    
    Prevent silent recurrence (gnucash_core.py):
    The write path failed *silently*, which is how this slipped through review and
    CI. num/denom are read-only accessor methods, so assigning to them merely
    shadows the method and changes nothing. Add a GncNumeric.__setattr__ that raises
    AttributeError on assignment to num/denom, pointing at the correct idiom
    GncNumeric(num, denom).
    
    Test (bindings/python/tests/test_numeric.py):
    New test_num_denom_read_only covering the guard. It needs no backend, so it runs
    in the existing python-bindings CTest on all platforms.
    
    The wiki page Stocks/get_prices, which the reporter copied from, is being
    updated separately.

diff --git a/bindings/python/example_scripts/priceDB_test.py b/bindings/python/example_scripts/priceDB_test.py
index 1e3cd6b288..dd4e458d47 100644
--- a/bindings/python/example_scripts/priceDB_test.py
+++ b/bindings/python/example_scripts/priceDB_test.py
@@ -41,12 +41,12 @@ for pr in pl:
    source = pr.get_source()
    time = pr.get_time64()
    v=pr.get_value()
-   price = float(v.num)/v.denom
+   price = float(v.num())/v.denom()
    print(time, source, price)
 
 if len(pl) > 0:
    v0 = pl[0].get_value()
-   print(arm.get_fullname(), float(v0.num) / float(v0.denom ))
+   print(arm.get_fullname(), float(v0.num()) / float(v0.denom()))
 
 session.end()
 session.destroy()
diff --git a/bindings/python/example_scripts/price_database_example.py b/bindings/python/example_scripts/price_database_example.py
index 032f973859..b8af8dd41a 100755
--- a/bindings/python/example_scripts/price_database_example.py
+++ b/bindings/python/example_scripts/price_database_example.py
@@ -82,7 +82,7 @@ for namespace in namespaces:
                source = pr.get_source()
                time = pr.get_time64()
                v=pr.get_value()
-               price = float(v.num)/v.denom
+               price = float(v.num())/v.denom()
 
                print("{0} {1:20}{2:10.4f} {3}".format(time,source,price,cur_name))
                # I didn't find out how to format the time option...
diff --git a/bindings/python/example_scripts/quotes_historic.py b/bindings/python/example_scripts/quotes_historic.py
index 77781b30ac..5996120c30 100644
--- a/bindings/python/example_scripts/quotes_historic.py
+++ b/bindings/python/example_scripts/quotes_historic.py
@@ -17,10 +17,9 @@
 #   https://wiki.gnucash.org/wiki/Stocks/get_prices
 #
 
-from gnucash import Session, Account, Split
-import gnucash
+from gnucash import Session, GncNumeric, GncPrice, PriceSource, GNC_HOW_DENOM_FIXED, GNC_HOW_RND_ROUND_HALF_UP
+from gnucash.gnucash_core_c import COMMODITY_DENOM_MULT
 import datetime
-from fractions import Fraction
 from gnc_convenience import find_account
 
 FILE = "./test.gnucash"
@@ -48,42 +47,35 @@ for i in range(1,len(data)):
     stock_price.append(float(data[i].rsplit(',')[5]))
 
 # Initialize Gnucash session
-session = Session(url, True, False, False)
+session = Session(url)
 root = session.book.get_root_account()
 book = session.book
 account = book.get_root_account()
 pdb = book.get_price_db()
 comm_table = book.get_table()
-ac = find_account(account,'Intel')[0] 
 
+# This example assumes the Intel holding is priced in USD.
+ac = find_account(account, 'Intel')[0]
 stock = ac.GetCommodity()
-# Add the prices
-pdb = book.get_price_db()
-if len(ac.GetSplitList())<1:
-  print('Need at least one Split to get currency info ... ')
-  raise SystemExit
-cur = ac.GetSplitList()[0].GetParent().GetCurrency()
-
-# Get stock data
-pl = pdb.get_prices(stock,cur)
-if len(pl)<1:
-  print('Need at least one database entry to clone ...')
-  raise SystemExit
-
-pl0 = pl[0]
-for i in range(1,len(pl)):
-  pdb.remove_price(pl[i])
+cur = comm_table.lookup("CURRENCY", "USD")
 
-for i in range(0,len(stock_date)):
-  p_new = pl0.clone(book)
-  p_new = gnucash.GncPrice(instance=p_new)
-  print('Adding',i,stock_date[i],stock_price[i])
+# Construct the price at the precision GnuCash uses for a commodity quoted in a
+# currency: the currency's smallest unit (its SCU -- get_fraction() is 100 for
+# USD, i.e. cents) times COMMODITY_DENOM_MULT (10000), the extra precision a
+# price carries beyond an ordinary amount. See "Price policy" in gnc-pricedb.h.
+price_denom = cur.get_fraction() * COMMODITY_DENOM_MULT
+for i in range(0, len(stock_date)):
+  print('Adding', i, stock_date[i], stock_price[i])
+  # --- Create a fresh price object with all properties set ------------------
+  p_new = GncPrice(book)
+  p_new.set_commodity(stock)
+  p_new.set_currency(cur)
   p_new.set_time64(stock_date[i])
-  v = p_new.get_value()
-  v.num = int(Fraction.from_float(stock_price[i]).limit_denominator(100000).numerator)
-  v.denom = int(Fraction.from_float(stock_price[i]).limit_denominator(100000).denominator)
-  p_new.set_value(v)
-  p_new.set_source("Finance::Quotes::Historic")
+  p_new.set_value(GncNumeric(stock_price[i], price_denom,
+                             GNC_HOW_DENOM_FIXED | GNC_HOW_RND_ROUND_HALF_UP))
+  p_new.set_source(PriceSource.FINANCE_QUOTE)
+  p_new.set_typestr("last")                    # bid / ask / last / nav / unknown
+  # --------------------------------------------------------------------------
   pdb.add_price(p_new)
 
 # Clean up
diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py
index acd6c1198c..9bf5a8d95a 100644
--- a/bindings/python/gnucash_core.py
+++ b/bindings/python/gnucash_core.py
@@ -564,6 +564,16 @@ class GncNumeric(GnuCashCoreClass):
             kargs['instance'] = GncNumeric.__args_to_instance(args)
         GnuCashCoreClass.__init__(self, [], **kargs)
 
+    def __setattr__(self, name, value):
+        # num and denom are read-only accessor methods, not writable fields.
+        if name in ('num', 'denom'):
+            raise AttributeError(
+                "Cannot assign to '%s': it is a read-only accessor method on "
+                "GncNumeric, not a writable field, so the assignment would not "
+                "change the value. Construct a new value instead, e.g. "
+                "GncNumeric(numerator, denominator)." % name)
+        super().__setattr__(name, value)
+
     @staticmethod
     def __args_to_instance(args):
         if len(args) == 0:
diff --git a/bindings/python/tests/test_numeric.py b/bindings/python/tests/test_numeric.py
index df16749fd9..654f497e3e 100644
--- a/bindings/python/tests/test_numeric.py
+++ b/bindings/python/tests/test_numeric.py
@@ -98,5 +98,18 @@ class TestGncNumeric(TestCase):
         with self.assertRaises(TypeError):
             GncNumeric(complex(1, 1))
 
+    def test_num_denom_read_only(self):
+        # num/denom are read-only accessor methods, not writable fields.
+        # Assigning to them used to silently do nothing and corrupt data
+        # (Bug 799796); it must now raise instead.
+        num = GncNumeric(1, 2)
+        with self.assertRaises(AttributeError):
+            num.num = 5
+        with self.assertRaises(AttributeError):
+            num.denom = 5
+        # The value is unchanged and the accessors still work.
+        self.assertEqual(num.num(), 1)
+        self.assertEqual(num.denom(), 2)
+
 if __name__ == '__main__':
     main()



Summary of changes:
 bindings/python/example_scripts/priceDB_test.py    |  4 +-
 .../example_scripts/price_database_example.py      |  2 +-
 bindings/python/example_scripts/quotes_historic.py | 52 +++++++++-------------
 bindings/python/gnucash_core.py                    | 10 +++++
 bindings/python/tests/test_numeric.py              | 13 ++++++
 5 files changed, 48 insertions(+), 33 deletions(-)



More information about the gnucash-changes mailing list