Hi All,
   I've been a happy GnuCash user for 13 years and am recently finding
more time and interest to take on some more advanced use cases and
also contribute to the GnuCash project. (and more bandwidth thanks to
AI assisted coding)

My projects all involve use of the official API python bindings.  So
you'll see some bug reports, PR, and other chatter from me about that.

Here's a matter that's within my skillet to fix, but the best approach
is debatable given one route involves breaking changes to the existing
python bindings.  So I wanted to seek other's opinions.

Background
-----------------------------

The Python bindings use a two-layer architecture: SWIG auto-generates
a low-level C API (gnucash_core_c), and gnucash_core.py wraps selected
methods so they return proper Python objects (GncPrice, GncCommodity,
etc.) instead of raw SWIG pointers. This wrapping is done via
methods_return_instance() dicts that map method names to their return
types.

The problem is that these dicts are incomplete. Many methods that
return pointers to GnuCash objects are exposed on the Python classes
(via add_methods_with_prefix) but never registered for return-type
wrapping. They silently return raw SwigPyObject pointers that have no
Python methods and can only be used via gnucash_core_c C-level
function calls.

This is confusing because the methods *appear* to work -- they're
callable and return non-None values -- but the return values are
unusable through the documented Python API. There's no error, no
warning, and no documentation indicating which methods are wrapped and
which aren't. The only way to discover the problem is to inspect
type() on the return value.

I've done a systematic audit of all classes in gnucash_core.py and
gnucash_business.py, cross-referencing the C functions exposed by SWIG
against the methods_return_instance dicts, and empirically verified
each finding. Below are the results.


Affected Classes and Methods
-----------------------------

1. GncPrice -- no wrapping dict exists at all
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

GncPrice is the worst case. bindings/python/gnucash_core.py line 741
calls add_methods_with_prefix('gnc_price_'), which exposes all
gnc_price_* functions as methods. But there is no
methods_return_instance call for GncPrice anywhere in that file -- not
a single method has its return type wrapped.

  Method            Currently returns                  Should return
  ----------------  --------------------------------   ----------------
  get_commodity()   SwigPyObject (raw gnc_commodity *)  GncCommodity
  get_currency()    SwigPyObject (raw gnc_commodity *)  GncCommodity
  clone(book)       SwigPyObject (raw GNCPrice *)       GncPrice
  get_value()       _gnc_numeric (SWIG proxy)           GncNumeric

get_value() is a partial case -- the _gnc_numeric SWIG proxy is usable
via GncNumeric(instance=val), but this is inconsistent with Split and
Transaction where equivalent methods (GetAmount, GetValue, GetBalance,
etc.) are all wrapped to return GncNumeric directly.

Suggested fix:

    GncPrice.add_methods_with_prefix('gnc_price_')

    gnc_price_dict = {
        'get_commodity': GncCommodity,
        'get_currency': GncCommodity,
        'clone': GncPrice,
        'get_value': GncNumeric,
    }
    methods_return_instance(GncPrice, gnc_price_dict)


2. GncPriceDB -- PriceDB_dict incomplete
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The existing PriceDB_dict wraps lookup_latest,
lookup_nearest_in_time64, lookup_nearest_before_t64, and some
convert_balance methods. But several methods that return the same
types are missing.

Missing single-value wrappers (should return GncPrice):

  Method                                   Currently returns
Should return
  ---------------------------------------- ------------------------
-----------
  nth_price(commodity, n)                  SwigPyObject (GNCPrice *)  GncPrice
  lookup_day_t64(commodity, currency, date) SwigPyObject (GNCPrice *)  GncPrice

Missing single-value wrapper (should return GncNumeric):

  Method                                          Currently returns
Should return
  ----------------------------------------------- --------------------
-----------
  convert_balance_nearest_before_price_t64(...)    _gnc_numeric (raw)
 GncNumeric

Its siblings convert_balance_latest_price and
convert_balance_nearest_price_t64 are correctly wrapped.

Missing list wrappers (should return list of GncPrice):

  Method                                             Currently returns
    Should return
  --------------------------------------------------
--------------------- ----------------
  lookup_latest_any_currency(commodity)
list[SwigPyObject]    list[GncPrice]
  lookup_nearest_before_any_currency_t64(comm, date)
list[SwigPyObject]    list[GncPrice]
  lookup_nearest_in_time_any_currency_t64(comm, date)
list[SwigPyObject]    list[GncPrice]

Note: get_latest_price, get_nearest_price, and get_nearest_before_price
are NOT bugs -- their C functions return gnc_numeric directly (the
price value, not a GNCPrice *), so the raw _gnc_numeric return is the
correct C type. They should arguably be wrapped to GncNumeric for
consistency, but that's a lower priority.

Suggested fix:

    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,
        'get_latest_price': GncNumeric,
        'get_nearest_price': GncNumeric,
        'get_nearest_before_price': GncNumeric,
    }
    methods_return_instance(GncPriceDB, PriceDB_dict)

    methods_return_instance_lists(GncPriceDB, {
        'get_prices': GncPrice,                              # already done
        'lookup_latest_any_currency': GncPrice,              # new
        'lookup_nearest_before_any_currency_t64': GncPrice,  # new
        'lookup_nearest_in_time_any_currency_t64': GncPrice, # new
    })


3. GncCommodity -- two missing wrappers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

GncCommodity.clone is correctly wrapped (gnucash_core.py line 979),
but two other methods that return object pointers are not.

  Method              Currently returns                          Should return
  ------------------  -----------------------------------------
----------------------
  obtain_twin(book)   SwigPyObject (raw gnc_commodity *)         GncCommodity
  get_namespace_ds()  SwigPyObject (raw gnc_commodity_namespace *)
GncCommodityNamespace

Additionally, get_quote_source() and get_default_quote_source() return
raw gnc_quote_source * pointers. However, gnc_quote_source has no
Python wrapper class, so these are a deeper design gap rather than a
simple omission -- there's nothing to wrap *to*. Currently the only
way to use them is via gnucash_core_c.gnc_quote_source_get_internal_name(ptr)
etc. A proper fix would require creating a new GncQuoteSource wrapper
class.


4. Account -- one missing wrapper
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  Method                    Currently returns                  Should return
  ------------------------  --------------------------------   ------------
  get_currency_or_parent()  SwigPyObject (raw gnc_commodity *) GncCommodity

The existing account_dict wraps GetCommodity -> GncCommodity but
misses get_currency_or_parent, which returns the same C type.


5. GncLot -- two missing wrappers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  Method                  Currently returns      Should return
  ----------------------  ---------------------  ---------------
  get_balance_before(sp)  raw _gnc_numeric       GncNumeric
  get_split_list()        list[SwigPyObject]     list[Split]

The existing gnclot_dict wraps get_balance -> GncNumeric but misses
get_balance_before. And get_split_list needs
method_function_returns_instance_list like Account.GetSplitList and
Transaction.GetSplitList already have.


The Breaking Change Problem
---------------------------

Every fix listed above changes a method's return type from a raw SWIG
pointer to a wrapped Python object. These are breaking changes: any
existing code that passes these return values to gnucash_core_c
C-level functions will break, because those functions expect the raw
SWIG pointer, not a wrapper.

For example, current workaround code looks like:

    from gnucash import gnucash_core_c as gc

    raw_price = pricedb.nth_price(commodity, 0)
    gc.gnc_price_get_currency(raw_price)      # works today
    gc.gnc_price_get_time64(raw_price)

After wrapping nth_price -> GncPrice:

    price = pricedb.nth_price(commodity, 0)    # now returns GncPrice
    gc.gnc_price_get_currency(price)           # BREAKS
    price.get_currency()                       # new correct usage

The workaround-after-the-fix is to use .instance to extract the raw
pointer:

    gc.gnc_price_get_currency(price.instance)  # works

How many users are affected?

Anyone using these methods today has already discovered the raw-pointer
problem through trial and error and written gnucash_core_c workarounds.
These workarounds are undocumented and fragile. The "break" moves users
from an undocumented workaround to the intended API.

That said, the Python bindings have been in this state for years, and
scripts using the C-level workarounds do exist in the wild (Stack
Overflow answers, wiki examples, personal scripts).


Possible Approaches
-------------------

I'd like the developers' input on how to handle this. Some options:

Option A: Fix everything, accept the break

  Add all missing entries to the methods_return_instance dicts.
  Document the change in release notes. This is the cleanest long-term
  outcome but breaks existing workaround code silently (no error --
  just wrong types passed to C functions, likely causing segfaults or
  TypeError).

Option B: Fix everything, add a compatibility shim

  Modify method_function_returns_instance (in function_class.py) so
  that wrapped objects are transparently accepted by gnucash_core_c
  functions. This could be done by making the wrapper classes implement
  __swig_convert__ or by patching process_list_convert_to_instance to
  unwrap at call boundaries. This would make both old and new code
  work, but adds complexity to the wrapping layer.

Option C: Fix only the most impactful methods, leave the rest

  Prioritize the methods most likely to be encountered by users:
  - GncPrice.get_commodity(), .get_currency()
  - GncPriceDB.nth_price()
  - Account.get_currency_or_parent()

  Leave edge cases like GncLot.get_balance_before() and
  GncCommodity.obtain_twin() for later.

Option D: Deprecation warnings first, fix later

  Add runtime deprecation warnings when unwrapped methods are called,
  pointing users to the upcoming change. Fix the return types in the
  next major release.

---

My preference is Option A with clear release notes because the
compatibility shim may be too complex. The current state is a usability
trap -- methods look like they work but return unusable objects -- and
fixing it benefits all future users even if it inconveniences the few
who have written workarounds.

I'm happy to submit patches for whichever approach the project prefers.


Appendix: Methodology
---------------------

All findings were verified empirically on GnuCash 5.14 built from
source (Python 3.11, Debian Bookworm, -DWITH_PYTHON=ON
-DWITH_GNUCASH=OFF). Each method was called against a test GnuCash
SQLite file containing accounts, commodities, and prices. Return types
were checked with type() and compared against the C function signatures
in gnc-pricedb.h, gnc-commodity.h, Account.h, and gnc-lot.h.

The full C API surface was enumerated via dir(gnucash_core_c) and
cross-referenced against the methods_return_instance dicts in
gnucash_core.py (lines 769-776, 960-974, 984-992, 1011-1020,
1029-1044, 1056-1074, 1085-1114) and gnucash_business.py.


Sincerely,

Noah Reddell
_______________________________________________
gnucash-devel mailing list
[email protected]
https://lists.gnucash.org/mailman/listinfo/gnucash-devel

Reply via email to