From ee3342d2b474fc94be206e1a768dc8de07b0ea32 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 22:43:08 +0200 Subject: [PATCH 01/17] introduce python submodule deprecation the deprecation submodule will house content related to deprecation. That is general convenience function and functions related to specific deprecation issues. The latter starts with decorator functions to bridge the change in qof_session_begin argument change to SessionOpenMode. --- bindings/python/CMakeLists.txt | 2 +- bindings/python/__init__.py | 1 + bindings/python/deprecation.py | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 bindings/python/deprecation.py diff --git a/bindings/python/CMakeLists.txt b/bindings/python/CMakeLists.txt index 8c5884e3a9..4e7480495e 100644 --- a/bindings/python/CMakeLists.txt +++ b/bindings/python/CMakeLists.txt @@ -1,7 +1,7 @@ add_subdirectory(example_scripts) add_subdirectory(tests) -set(PYEXEC_FILES __init__.py function_class.py gnucash_business.py gnucash_core.py app_utils.py) +set(PYEXEC_FILES __init__.py function_class.py gnucash_business.py gnucash_core.py app_utils.py deprecation.py) set(SWIG_FILES ${CMAKE_CURRENT_SOURCE_DIR}/gnucash_core.i ${CMAKE_CURRENT_SOURCE_DIR}/time64.i) set(GNUCASH_CORE_C_INCLUDES diff --git a/bindings/python/__init__.py b/bindings/python/__init__.py index 16b72fba9d..8b3e0b6870 100644 --- a/bindings/python/__init__.py +++ b/bindings/python/__init__.py @@ -5,6 +5,7 @@ # >>> from gnucash.gnucash_core import thingy from gnucash.gnucash_core import * from . import app_utils +from . import deprecation ## @file # @brief helper file for the importing of gnucash # @author Mark Jenkins, ParIT Worker Co-operative diff --git a/bindings/python/deprecation.py b/bindings/python/deprecation.py new file mode 100644 index 0000000000..81d4cdc362 --- /dev/null +++ b/bindings/python/deprecation.py @@ -0,0 +1,68 @@ +# deprecation.py - gnucash submodule with deprecation related content +# +# contains decorator methods dealing with deprecated methods and +# deprecation related convenience methods +# +# @brief gnucash submodule with deprecation related content +# @author Christoph Holtermann +# @ingroup python_bindings + +from functools import wraps + +# use of is_new, force_new and ignore_lock is deprecated, use mode instead +# the following decorators enable backward compatibility for the deprecation period +def deprecated_args_session(ignore_lock_or_mode=None, is_new=None, + force_new=None, mode=None, ignore_lock=None): + + # check for usage of deprecated arguments (ignore_lock, is_new, force_new) + deprecated_args = (ignore_lock, is_new, force_new) + deprecated_keyword_use = deprecated_args.count(None) != len(deprecated_args) + if deprecated_keyword_use: + # deprecated arguments have been used by keyword or more than three args have been used which is only possible with the deprecated args + deprecation = True + else: + deprecation = False + # __init__ could have been called without keywords like __init__(book_uri, True) where True aims at ignore_lock + # which ist not distinguishable from __init__(book, SessionOpenMode.SESSION_NORMAL_OPEN) + # so if mode has not been set by keyword use the 3rd argument + if mode is None: + mode = ignore_lock_or_mode + + if deprecation: + # if any(item in ("is_new", "ignore_lock", "force_new") for item in kwargs): + import warnings + warnings.warn( + "Use of ignore_lock, is_new or force_new arguments is deprecated. Use mode argument instead. Have a look at gnucash.SessionOpenMode.", + category=DeprecationWarning, + stacklevel=3 + ) + + # if not provided calculate mode from deprecated args + if mode is None: + from gnucash.gnucash_core import SessionOpenMode + ignore_lock = False if ignore_lock is None else ignore_lock + is_new = False if is_new is None else is_new + force_new = False if force_new is None else force_new + mode = SessionOpenMode((ignore_lock << 2) + (is_new << 1) + force_new) + + return mode + +def deprecated_args_session_init(original_function): + """decorator for Session.__init__() to provide backward compatibility for deprecated use of ignore_lock, is_new and force_new""" + @wraps(original_function) + def new_function(self, book_uri=None, ignore_lock_or_mode=None, is_new=None, + force_new=None, instance=None, mode=None, ignore_lock=None): + + mode = deprecated_args_session(ignore_lock_or_mode, is_new, force_new, mode, ignore_lock) + return(original_function(self, book_uri=book_uri, mode=mode, instance=instance)) + return new_function + +def deprecated_args_session_begin(original_function): + """decorator for Session.begin() to provide backward compatibility for deprecated use of ignore_lock, is_new and force_new""" + @wraps(original_function) + def new_function(self, new_uri=None, ignore_lock_or_mode=None, is_new=None, + force_new=None, mode=None, ignore_lock=None): + mode = deprecated_args_session(ignore_lock_or_mode, is_new, force_new, mode, ignore_lock) + return(original_function(self, new_uri=new_uri, mode=mode)) + return new_function + From 48072f5a4c957e5ef4a168f8774006872cd4acf7 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Thu, 11 Jun 2020 17:50:49 +0200 Subject: [PATCH 02/17] make SessionOpenMode enum available for python --- bindings/python/gnucash_core.py | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index b82c5a53b9..5dbf683837 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -28,6 +28,7 @@ # @author Jeff Green, ParIT Worker Co-operative # @ingroup python_bindings +from enum import IntEnum from gnucash import gnucash_core_c from gnucash import _sw_core_utils @@ -83,6 +84,57 @@ class GnuCashBackendException(Exception): Exception.__init__(self, msg) self.errors = errors + +class SessionOpenMode(IntEnum): + """Mode for opening sessions. + + This replaces three booleans that were passed in order: ignore_lock, create, + and force. It's structured so that one can use it as a bit field with the + values in the same order, i.e. ignore_lock = 1 << 2, create_new = 1 << 1, and + force_new = 1. + + enumeration members + ------------------- + + SESSION_NORMAL_OPEN = 0 (All False) + Open will fail if the URI doesn't exist or is locked. + + SESSION_NEW_STORE = 2 (False, True, False (create)) + Create a new store at the URI. It will fail if the store already exists and is found to contain data that would be overwritten. + + SESSION_NEW_OVERWRITE = 3 (False, True, True (create | force)) + Create a new store at the URI even if a store already exists there. + + SESSION_READ_ONLY = 4, (True, False, False (ignore_lock)) + Open the session read-only, ignoring any existing lock and not creating one if the URI isn't locked. + + SESSION_BREAK_LOCK = 5 (True, False, True (ignore_lock | force)) + Open the session, taking over any existing lock. + + source: lignucash/engine/qofsession.h + """ + + SESSION_NORMAL_OPEN = gnucash_core_c.SESSION_NORMAL_OPEN + """All False + Open will fail if the URI doesn't exist or is locked.""" + + SESSION_NEW_STORE = gnucash_core_c.SESSION_NEW_STORE + """False, True, False (create) + Create a new store at the URI. It will fail if the store already exists and is found to contain data that would be overwritten.""" + + SESSION_NEW_OVERWRITE = gnucash_core_c.SESSION_NEW_OVERWRITE + """False, True, True (create | force) + Create a new store at the URI even if a store already exists there.""" + + SESSION_READ_ONLY = gnucash_core_c.SESSION_READ_ONLY + """True, False, False (ignore_lock) + Open the session read-only, ignoring any existing lock and not creating one if the URI isn't locked.""" + + SESSION_BREAK_LOCK = gnucash_core_c.SESSION_BREAK_LOCK + """True, False, True (ignore_lock | force) + Open the session, taking over any existing lock.""" + + class Session(GnuCashCoreClass): """A GnuCash book editing session From 4e280b959349e420e34ca93938982fb4e39481bd Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Tue, 9 Jun 2020 22:41:20 +0200 Subject: [PATCH 03/17] adapt to use of sessionOpenMode in qof_session_begin --- bindings/python/gnucash_core.py | 72 +++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index 5dbf683837..fa116b1d7d 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -48,6 +48,12 @@ from gnucash.gnucash_core_c import gncInvoiceLookup, gncInvoiceGetInvoiceFromTxn gnc_numeric_create, double_to_gnc_numeric, string_to_gnc_numeric, \ gnc_numeric_to_string +from gnucash.deprecation import ( + deprecated_args_session, + deprecated_args_session_init, + deprecated_args_session_begin +) + try: import gettext @@ -148,44 +154,75 @@ class Session(GnuCashCoreClass): Invoice..) is associated with a particular book where it is stored. """ - def __init__(self, book_uri=None, ignore_lock=False, is_new=False, - force_new=False, instance=None): - """A convenient constructor that allows you to specify a book URI, + @deprecated_args_session_init + def __init__(self, book_uri=None, mode=None, instance=None, book=None): + """! + A convenient constructor that allows you to specify a book URI, begin the session, and load the book. This can give you the power of calling qof_session_new, qof_session_begin, and qof_session_load all in one! - book_uri can be None to skip the calls to qof_session_begin and - qof_session_load, or it can be a string like "file:/test.xac" + qof_session_load is only called if url scheme is "xml" and + mode is SESSION_NEW_STORE or SESSION_NEW_OVERWRITE - qof_session_load is only called if is_new is set to False + @param book_uri must be a string in the form of a URI/URL. The access + method specified depends on the loaded backends. Paths may be relative + or absolute. If the path is relative, that is if the argument is + "file://somefile.xml", then the current working directory is + assumed. Customized backends can choose to search other + application-specific directories or URI schemes as well. + It be None to skip the calls to qof_session_begin and + qof_session_load. - is_new is passed to qof_session_begin as the argument create, - and force_new as the argument force. Is_new will create a new - database or file; force will force creation even if it will - destroy an existing dataset. - - ignore_lock is passed to qof_session_begin's argument of the - same name and is used to break an existing lock on a dataset. - - instance argument can be passed if new Session is used as a + @param instance argument can be passed if new Session is used as a wrapper for an existing session instance + @param mode The SessionOpenMode. + @note SessionOpenMode replaces deprecated ignore_lock, is_new and force_new. - This function can raise a GnuCashBackendException. If it does, + @par SessionOpenMode + `SESSION_NORMAL`: Find an existing file or database at the provided uri and + open it if it is unlocked. If it is locked post a QOF_BACKEND_LOCKED error. + @par + `SESSION_NEW_STORE`: Check for an existing file or database at the provided + uri and if none is found, create it. If the file or database exists post a + QOF_BACKED_STORE_EXISTS and return. + @par + `SESSION_READ_ONLY`: Find an existing file or database and open it without + disturbing the lock if it exists or setting one if not. This will also set a + flag on the book that will prevent many elements from being edited and will + prevent the backend from saving any edits. + @par + `SESSION_OVERWRITE`: Create a new file or database at the provided uri, + deleting any existing file or database. + @par + `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open + it. If there is already a lock replace it with a new one for this session. + + @par Errors + qof_session_begin() signals failure by queuing errors. After it completes use + qof_session_get_error() and test that the value is `ERROR_BACKEND_NONE` to + determine that the session began successfully. + + @exception as begin() and load() are wrapped with raise_backend_errors_after_call() + this function can raise a GnuCashBackendException. If it does, you don't need to cleanup and call end() and destroy(), that is handled for you, and the exception is raised. """ GnuCashCoreClass.__init__(self, Book()) + if book_uri is not None: try: - self.begin(book_uri, ignore_lock, is_new, force_new) + if mode is None: + mode = SessionOpenMode.SESSION_NORMAL_OPEN + self.begin(book_uri, mode) # Take care of backend inconsistency # New xml file can't be loaded, new sql store # has to be loaded before it can be altered # Any existing store obviously has to be loaded # More background: https://bugs.gnucash.org/show_bug.cgi?id=726891 + is_new = mode in (SessionOpenMode.SESSION_NEW_STORE, SessionOpenMode.SESSION_NEW_OVERWRITE) if book_uri[:3] != "xml" or not is_new: self.load() except GnuCashBackendException as backend_exception: @@ -585,6 +622,7 @@ Session.decorate_functions(one_arg_default_none, "load", "save") Session.decorate_functions( Session.raise_backend_errors_after_call, "begin", "load", "save", "end") +Session.decorate_functions(deprecated_args_session_begin, "begin") Session.get_book = method_function_returns_instance( Session.get_book, Book ) From b073dbc5c323f88363e97231afc37fab017dfa8c Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Thu, 11 Jun 2020 21:11:06 +0200 Subject: [PATCH 04/17] allow keyword arguments for function_class.py allow keyword arguments for function_class methods and functions. process_dict_convert_to_instance() is added to mimic the behavior of the process_list_convert_to_instance() Derived methods in gnucash_core.py like raise_backend_errors_after_call get modified to accept being called with keyword args. Also adds some docstrings. --- bindings/python/function_class.py | 108 ++++++++++++++++++++++++------ bindings/python/gnucash_core.py | 6 +- 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/bindings/python/function_class.py b/bindings/python/function_class.py index 81bebb049e..fc99cc80fa 100644 --- a/bindings/python/function_class.py +++ b/bindings/python/function_class.py @@ -71,7 +71,8 @@ class ClassFromFunctions(object): self.__instance = kargs[INSTANCE_ARGUMENT] else: self.__instance = getattr(self._module, self._new_instance)( - *process_list_convert_to_instance(args) ) + *process_list_convert_to_instance(args), + **process_dict_convert_to_instance(kargs)) def get_instance(self): """Get the instance data. @@ -86,12 +87,29 @@ class ClassFromFunctions(object): @classmethod def add_method(cls, function_name, method_name): - """Add the function, method_name to this class as a method named name - """ - def method_function(self, *meth_func_args): + """! Add the function, method_name to this class as a method named name + + arguments: + @param cls Class: class to add methods to + @param function_name string: name of the function to add + @param method_name string: name of the method that function will be called + + function will be wrapped by method_function""" + + def method_function(self, *meth_func_args, **meth_func_kargs): + """! wrapper method for function + + arguments: + @param self: FunctionClass instance. Will be turned to its instance property. + @param *meth_func_args: arguments to be passed to function. All FunctionClass + objects will be turned to their respective instances. + @param **meth_func_kargs: keyword arguments to be passed to function. All + FunctionClass objects will be turned to their respective instances.""" return getattr(self._module, function_name)( self.instance, - *process_list_convert_to_instance(meth_func_args) ) + *process_list_convert_to_instance(meth_func_args), + **process_dict_convert_to_instance(meth_func_kargs) + ) setattr(cls, method_name, method_function) setattr(method_function, "__name__", method_name) @@ -99,14 +117,32 @@ class ClassFromFunctions(object): @classmethod def ya_add_classmethod(cls, function_name, method_name): - """Add the function, method_name to this class as a classmethod named name + """! Add the function, method_name to this class as a classmethod named name - Taken from function_class and slightly modified. - """ - def method_function(self, *meth_func_args): + Taken from function_class and modified from add_method() to add classmethod + instead of method and not to turn self argument to self.instance. + + arguments: + @param cls Class: class to add methods to + @param function_name string: name of the function to add + @param method_name string: name of the classmethod that function will be called + + function will be wrapped by method_function""" + + def method_function(self, *meth_func_args, **meth_func_kargs): + """! wrapper method for function + + arguments: + @param self: FunctionClass instance. + @param *meth_func_args: arguments to be passed to function. All FunctionClass + objects will be turned to their respective instances. + @param **meth_func_kargs: keyword arguments to be passed to function. All + FunctionClass objects will be turned to their respective instances.""" return getattr(self._module, function_name)( self, - *process_list_convert_to_instance(meth_func_args) ) + *process_list_convert_to_instance(meth_func_args), + **process_dict_convert_to_instance(meth_func_kargs) + ) setattr(cls, method_name, classmethod(method_function)) setattr(method_function, "__name__", method_name) @@ -114,14 +150,32 @@ class ClassFromFunctions(object): @classmethod def ya_add_method(cls, function_name, method_name): - """Add the function, method_name to this class as a method named name + """! Add the function, method_name to this class as a method named name - Taken from function_class and slightly modified. - """ - def method_function(self, *meth_func_args): + Taken from function_class. Modified to not turn self to self.instance + as add_method() does. + + arguments: + @param cls Class: class to add methods to + @param function_name string: name of the function to add + @param method_name string: name of the method that function will be called + + function will be wrapped by method_function""" + + def method_function(self, *meth_func_args, **meth_func_kargs): + """! wrapper method for function + + arguments: + @param self: FunctionClass instance. + @param *meth_func_args: arguments to be passed to function. All FunctionClass + objects will be turned to their respective instances. + @param **meth_func_kargs: keyword arguments to be passed to function. All + FunctionClass objects will be turned to their respective instances.""" return getattr(self._module, function_name)( self, - *process_list_convert_to_instance(meth_func_args) ) + *process_list_convert_to_instance(meth_func_args), + **process_dict_convert_to_instance(meth_func_kargs) + ) setattr(cls, method_name, method_function) setattr(method_function, "__name__", method_name) @@ -161,19 +215,19 @@ def method_function_returns_instance(method_function, cls): argument. """ assert( 'instance' == INSTANCE_ARGUMENT ) - def new_function(*args): - kargs = { INSTANCE_ARGUMENT : method_function(*args) } - if kargs['instance'] == None: + def new_function(*args, **kargs): + kargs_cls = { INSTANCE_ARGUMENT : method_function(*args, **kargs) } + if kargs_cls['instance'] == None: return None else: - return cls( **kargs ) + return cls( **kargs_cls ) return new_function def method_function_returns_instance_list(method_function, cls): - def new_function(*args): + def new_function(*args, **kargs): return [ cls( **{INSTANCE_ARGUMENT: item} ) - for item in method_function(*args) ] + for item in method_function(*args, **kargs) ] return new_function def methods_return_instance_lists(cls, function_dict): @@ -213,6 +267,18 @@ def process_list_convert_to_instance( value_list ): return [ return_instance_if_value_has_it(value) for value in value_list ] +def process_dict_convert_to_instance(value_dict): + """Return a dict built from value_dict, where if a value is in an instance + of ClassFromFunctions, we put value.instance in the dict instead. + + Things that are not instances of ClassFromFunctions are returned to + the new dict unchanged. + """ + return { + key: return_instance_if_value_has_it(value) for key, value in value_dict.items() + } + + def extract_attributes_with_prefix(obj, prefix): """Generator that iterates through the attributes of an object and for any attribute that matches a prefix, this yields diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index fa116b1d7d..32c33bca32 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -266,12 +266,12 @@ class Session(GnuCashCoreClass): # STATIC METHODS @staticmethod - def raise_backend_errors_after_call(function): + def raise_backend_errors_after_call(function, *args, **kwargs): """A function decorator that results in a call to raise_backend_errors after execution. """ - def new_function(self, *args): - return_value = function(self, *args) + def new_function(self, *args, **kwargs): + return_value = function(self, *args, **kwargs) self.raise_backend_errors(function.__name__) return return_value return new_function From ee77b713c235e8eb0ee73710bdb35f4918e363a6 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 12 Jun 2020 12:24:05 +0200 Subject: [PATCH 05/17] update example scripts to SessionOpenMode --- .../python/example_scripts/account_analysis.py | 4 ++-- .../python/example_scripts/gncinvoice_jinja.py | 14 +++++++++++--- bindings/python/example_scripts/latex_invoices.py | 8 +++++++- .../new_book_with_opening_balances.py | 7 ++++--- .../example_scripts/rest-api/gnucash_rest.py | 7 +++++-- bindings/python/example_scripts/simple_book.py | 4 ++-- .../example_scripts/simple_business_create.py | 4 ++-- .../example_scripts/simple_invoice_insert.py | 4 ++-- bindings/python/example_scripts/simple_session.py | 8 +++++--- .../python/example_scripts/simple_sqlite_create.py | 4 ++-- bindings/python/example_scripts/simple_test.py | 7 ++++--- 11 files changed, 46 insertions(+), 25 deletions(-) diff --git a/bindings/python/example_scripts/account_analysis.py b/bindings/python/example_scripts/account_analysis.py index 135d2e60ef..fe24a2e219 100644 --- a/bindings/python/example_scripts/account_analysis.py +++ b/bindings/python/example_scripts/account_analysis.py @@ -35,7 +35,7 @@ from math import log10 import csv # gnucash imports -from gnucash import Session, GncNumeric, Split +from gnucash import Session, GncNumeric, Split, SessionOpenMode # Invoke this script like the following example # $ python3 account_analysis.py gnucash_file.gnucash \ @@ -173,7 +173,7 @@ def main(): account_path = argv[8:] - gnucash_session = Session(gnucash_file, is_new=False) + gnucash_session = Session(gnucash_file, SessionOpenMode.SESSION_NORMAL_OPEN) root_account = gnucash_session.book.get_root_account() account_of_interest = account_from_path(root_account, account_path) diff --git a/bindings/python/example_scripts/gncinvoice_jinja.py b/bindings/python/example_scripts/gncinvoice_jinja.py index f44f308ab2..bfa7c09a58 100755 --- a/bindings/python/example_scripts/gncinvoice_jinja.py +++ b/bindings/python/example_scripts/gncinvoice_jinja.py @@ -39,6 +39,7 @@ try: import str_methods import jinja2 from gncinvoicefkt import * + from gnucash import SessionOpenMode except ImportError as import_error: print("Problem importing modules.") print(import_error) @@ -137,7 +138,7 @@ def main(argv=None): print("or file://filename") print("or mysql://user:password@host/databasename") print() - print("-f force open = ignore lock") + print("-f force open = ignore lock (read only)") print("-l list all invoices") print("-h or --help for this help") print("-I ID use invoice ID") @@ -150,8 +151,15 @@ def main(argv=None): # Try to open the given input try: - print("Opening", input_url, ".") - session = gnucash.Session(input_url, ignore_lock=ignore_lock) + print( + "Opening", input_url, " (ignore-lock = read-only)." if ignore_lock else "." + ) + session = gnucash.Session( + input_url, + SessionOpenMode.SESSION_READ_ONLY + if ignore_lock + else SessionOpenMode.SESSION_NORMAL_OPEN, + ) except Exception as exception: print("Problem opening input.") print(exception) diff --git a/bindings/python/example_scripts/latex_invoices.py b/bindings/python/example_scripts/latex_invoices.py index 2937c980a1..c29922e05d 100644 --- a/bindings/python/example_scripts/latex_invoices.py +++ b/bindings/python/example_scripts/latex_invoices.py @@ -64,6 +64,7 @@ try: from gnucash.gnucash_business import Customer, Employee, Vendor, Job, \ Address, Invoice, Entry, TaxTable, TaxTableEntry, GNC_AMT_TYPE_PERCENT, \ GNC_DISC_PRETAX + from gnucash import SessionOpenMode import locale except ImportError as import_error: print("Problem importing modules.") @@ -236,7 +237,12 @@ def main(argv=None): # Try to open the given input try: - session = gnucash.Session(input_url,ignore_lock=ignore_lock) + session = gnucash.Session( + input_url, + SessionOpenMode.SESSION_READ_ONLY + if ignore_lock + else SessionOpenMode.SESSION_NORMAL_OPEN, + ) except Exception as exception: print("Problem opening input.") print(exception) diff --git a/bindings/python/example_scripts/new_book_with_opening_balances.py b/bindings/python/example_scripts/new_book_with_opening_balances.py index df2d29ae7a..3a2d04e7bb 100644 --- a/bindings/python/example_scripts/new_book_with_opening_balances.py +++ b/bindings/python/example_scripts/new_book_with_opening_balances.py @@ -28,7 +28,8 @@ # @author Mark Jenkins, ParIT Worker Co-operative # @ingroup python_bindings_examples -from gnucash import Session, Account, Transaction, Split, GncNumeric +from gnucash import ( + Session, Account, Transaction, Split, GncNumeric, SessionOpenMode) from gnucash.gnucash_core_c import \ GNC_DENOM_AUTO, GNC_HOW_DENOM_EXACT, \ ACCT_TYPE_ASSET, ACCT_TYPE_BANK, ACCT_TYPE_CASH, ACCT_TYPE_CHECKING, \ @@ -299,8 +300,8 @@ def main(): #have everything in a try block to unable us to release our hold on stuff to the extent possible try: - original_book_session = Session(argv[1], is_new=False) - new_book_session = Session(argv[2], is_new=True) + original_book_session = Session(argv[1], SessionOpenMode.SESSION_NORMAL_OPEN) + new_book_session = Session(argv[2], SessionOpenMode.SESSION_NEW_STORE) new_book = new_book_session.get_book() new_book_root = new_book.get_root_account() diff --git a/bindings/python/example_scripts/rest-api/gnucash_rest.py b/bindings/python/example_scripts/rest-api/gnucash_rest.py index 51c75eb096..5334471396 100644 --- a/bindings/python/example_scripts/rest-api/gnucash_rest.py +++ b/bindings/python/example_scripts/rest-api/gnucash_rest.py @@ -68,6 +68,8 @@ from gnucash import \ from gnucash import \ INVOICE_IS_PAID +from gnucash import SessionOpenMode + app = Flask(__name__) app.debug = True @@ -1884,7 +1886,7 @@ for option, value in options: #start gnucash session base on connection string argument if is_new: - session = gnucash.Session(arguments[0], is_new=True) + session = gnucash.Session(arguments[0], SessionOpenMode.SESSION_NEW_STORE) # seem to get errors if we use the session directly, so save it and #destroy it so it's no longer new @@ -1893,7 +1895,8 @@ if is_new: session.end() session.destroy() -session = gnucash.Session(arguments[0], ignore_lock=True) +# unsure about SESSION_BREAK_LOCK - it used to be ignore_lock=True +session = gnucash.Session(arguments[0], SessionOpenMode.SESSION_BREAK_LOCK) # register method to close gnucash connection gracefully atexit.register(shutdown) diff --git a/bindings/python/example_scripts/simple_book.py b/bindings/python/example_scripts/simple_book.py index 19ebb01865..0059ff64e8 100644 --- a/bindings/python/example_scripts/simple_book.py +++ b/bindings/python/example_scripts/simple_book.py @@ -5,13 +5,13 @@ # @ingroup python_bindings_examples import sys -from gnucash import Session +from gnucash import Session, SessionOpenMode # We need to tell GnuCash the data format to create the new file as (xml://) uri = "xml:///tmp/simple_book.gnucash" print("uri:", uri) -with Session(uri, is_new=True) as ses: +with Session(uri, SessionOpenMode.SESSION_NEW_STORE) as ses: book = ses.get_book() #Call some methods that produce output to show that Book works diff --git a/bindings/python/example_scripts/simple_business_create.py b/bindings/python/example_scripts/simple_business_create.py index bb00846df9..e0df30bb80 100644 --- a/bindings/python/example_scripts/simple_business_create.py +++ b/bindings/python/example_scripts/simple_business_create.py @@ -53,7 +53,7 @@ from os.path import abspath from sys import argv, exit import datetime from datetime import timedelta -from gnucash import Session, Account, GncNumeric +from gnucash import Session, Account, GncNumeric, SessionOpenMode from gnucash.gnucash_business import Customer, Employee, Vendor, Job, \ Address, Invoice, Entry, TaxTable, TaxTableEntry, GNC_AMT_TYPE_PERCENT, \ GNC_DISC_PRETAX @@ -70,7 +70,7 @@ if len(argv) < 2: try: - s = Session(argv[1], is_new=True) + s = Session(argv[1], SessionOpenMode.SESSION_NEW_STORE) book = s.book root = book.get_root_account() diff --git a/bindings/python/example_scripts/simple_invoice_insert.py b/bindings/python/example_scripts/simple_invoice_insert.py index eef4c03baa..ee1bbfb063 100644 --- a/bindings/python/example_scripts/simple_invoice_insert.py +++ b/bindings/python/example_scripts/simple_invoice_insert.py @@ -46,7 +46,7 @@ # @author Mark Jenkins, ParIT Worker Co-operative # @ingroup python_bindings_examples -from gnucash import Session, GUID, GncNumeric +from gnucash import Session, GUID, GncNumeric, SessionOpenMode from gnucash.gnucash_business import Customer, Invoice, Entry from gnucash.gnucash_core_c import string_to_guid from os.path import abspath @@ -86,7 +86,7 @@ def gnc_numeric_from_decimal(decimal_value): return GncNumeric(numerator, denominator) -s = Session(argv[1], is_new=False) +s = Session(argv[1], SessionOpenMode.SESSION_NORMAL_OPEN) book = s.book root = book.get_root_account() diff --git a/bindings/python/example_scripts/simple_session.py b/bindings/python/example_scripts/simple_session.py index 05da9487ba..daebfdf649 100644 --- a/bindings/python/example_scripts/simple_session.py +++ b/bindings/python/example_scripts/simple_session.py @@ -3,9 +3,11 @@ # @brief Example Script simple session # @ingroup python_bindings_examples -from gnucash import \ - Session, GnuCashBackendException, \ +from gnucash import ( + Session, GnuCashBackendException, + SessionOpenMode, ERR_BACKEND_LOCKED, ERR_FILEIO_FILE_NOT_FOUND +) FILE_1 = "/tmp/not_there.xac" FILE_2 = "/tmp/example_file.xac" @@ -19,7 +21,7 @@ except GnuCashBackendException as backend_exception: # create a new file, this requires a file type specification -with Session("xml://%s" % FILE_2, is_new=True) as session: +with Session("xml://%s" % FILE_2, SessionOpenMode.SESSION_NEW_STORE) as session: book = session.book root = book.get_root_account() diff --git a/bindings/python/example_scripts/simple_sqlite_create.py b/bindings/python/example_scripts/simple_sqlite_create.py index 7900c9534d..61675be089 100644 --- a/bindings/python/example_scripts/simple_sqlite_create.py +++ b/bindings/python/example_scripts/simple_sqlite_create.py @@ -3,11 +3,11 @@ # @brief Example Script simple sqlite create # @ingroup python_bindings_examples -from gnucash import Session, Account +from gnucash import Session, Account, SessionOpenMode from os.path import abspath from gnucash.gnucash_core_c import ACCT_TYPE_ASSET -s = Session('sqlite3://%s' % abspath('test.blob'), is_new=True) +s = Session('sqlite3://%s' % abspath('test.blob'), SessionOpenMode.SESSION_NEW_STORE) # this seems to make a difference in more complex cases s.save() diff --git a/bindings/python/example_scripts/simple_test.py b/bindings/python/example_scripts/simple_test.py index b8a1bed02e..7101823dfa 100644 --- a/bindings/python/example_scripts/simple_test.py +++ b/bindings/python/example_scripts/simple_test.py @@ -3,11 +3,12 @@ # @brief Creates a basic set of accounts and a couple of transactions # @ingroup python_bindings_examples -from gnucash import Session, Account, Transaction, Split, GncNumeric +from gnucash import ( + Session, Account, Transaction, Split, GncNumeric, SessionOpenMode) FILE_1 = "/tmp/example.gnucash" -with Session("xml://%s" % FILE_1, is_new=True) as session: +with Session("xml://%s" % FILE_1, SessionOpenMode.SESSION_NEW_STORE) as session: book = session.book root_acct = Account(book) @@ -80,4 +81,4 @@ with Session("xml://%s" % FILE_1, is_new=True) as session: trans1.CommitEdit() - trans2.CommitEdit() \ No newline at end of file + trans2.CommitEdit() From c222503f42fd47ef973b0cfd49457de96f12a694 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 19:08:00 +0200 Subject: [PATCH 06/17] add method decorate_method to function_class.py ClassFromFunctions.decorate_method() allows to provide positional and keyword arguments for the decorator call besides the wrapped method. --- bindings/python/function_class.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bindings/python/function_class.py b/bindings/python/function_class.py index fc99cc80fa..31559f6c16 100644 --- a/bindings/python/function_class.py +++ b/bindings/python/function_class.py @@ -207,6 +207,22 @@ class ClassFromFunctions(object): setattr( cls, function_name, decorator( getattr(cls, function_name) ) ) + @classmethod + def decorate_method(cls, decorator, method_name, *args, **kargs): + """! decorate method method_name of class cls with decorator decorator + + in difference to decorate_functions() this allows to provide additional + arguments for the decorator function. + + arguments: + @param cls: class + @param decorator: function to decorate method + @param method_name: name of method to decorate (string) + @param *args: positional arguments for decorator + @param **kargs: keyword arguments for decorator""" + setattr(cls, method_name, + decorator(getattr(cls, method_name), *args, **kargs)) + def method_function_returns_instance(method_function, cls): """A function decorator that is used to decorate method functions that return instance data, to return instances instead. From 17d606e1f80915fd201606eac9d1f67c5ad0d536 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 19:14:20 +0200 Subject: [PATCH 07/17] enable keyword arguments for default_arguments_decorator default_arguments_decorator until now only allows positional argument defaults. This adds keyword defaults. The keywords can be mapped to the positional arguments by optional argument kargs_pos so interactions between keyword and positional arg defaults can raise a TypeError. Some more information in the docstring is included. In addition the docstring of the wrapped function will be modified to contain information about the defaults. --- bindings/python/function_class.py | 85 +++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/bindings/python/function_class.py b/bindings/python/function_class.py index 31559f6c16..5ed9f82f8c 100644 --- a/bindings/python/function_class.py +++ b/bindings/python/function_class.py @@ -252,18 +252,93 @@ def methods_return_instance_lists(cls, function_dict): method_function_returns_instance_list( getattr(cls, func_name), instance_name)) -def default_arguments_decorator(function, *args): - """Decorates a function to give it default, positional arguments +def default_arguments_decorator(function, *args, **kargs): + """! Decorates a function to give it default, positional and keyword arguments + + mimics python behavior when setting defaults in function/method arguments. + arguments can be set for positional or keyword arguments. + + kargs_pos contains positions of the keyword arguments. + @exception A TypeError will be raised if an argument is set as a positional and keyword argument + at the same time. + @note It might be possible to get keyword argument positional information using + introspection to avoid having to specify them manually + + a keyword argument default will be overwritten by a positional argument at the + actual function call + + this function modifies the docstring of the wrapped funtion to reflect + the defaults. You can't use this decorator with @, because this function has more than one argument. + + arguments: + @param *args: optional positional defaults + @param kargs_pos: dict with keyword arguments as key and their position in the argument list as value + @param **kargs: optional keyword defaults + + @return new_function wrapping original function """ - def new_function(*function_args): + + def new_function(*function_args, **function_kargs): + kargs_pos = {} + if "kargs_pos" in kargs: + kargs_pos = kargs.pop("kargs_pos") new_argset = list(function_args) - new_argset.extend( args[ len(function_args): ] ) - return function( *new_argset ) + new_argset.extend(args[len(function_args) :]) + new_kargset = {**kargs, **function_kargs} + for karg_pos in kargs_pos: + if karg_pos in new_kargset: + pos_karg = kargs_pos[karg_pos] + if pos_karg < len(new_argset): + new_kargset.pop(karg_pos) + + return function(*new_argset, **new_kargset) + + kargs_pos = {} if "kargs_pos" not in kargs else kargs["kargs_pos"] + for karg_pos in kargs_pos: + if karg_pos in kargs: + pos_karg = kargs_pos[karg_pos] + if pos_karg < len(args): + raise TypeError( + "default_arguments_decorator() got multiple values for argument '%s'" + % karg_pos + ) + + if new_function.__doc__ is None: + new_function.__doc__ = "" + if len(args): + firstarg = True + new_function.__doc__ += "positional argument defaults:\n" + for arg in args: + if not firstarg: + new_function.__doc__ += ", " + else: + new_function.__doc__ += " " + firstarg = False + new_function.__doc__ += str(arg) + new_function.__doc__ += "\n" + if len(kargs): + new_function.__doc__ += "keyword argument defaults:\n" + for karg in kargs: + if karg != "kargs_pos": + new_function.__doc__ += ( + " " + str(karg) + " = " + str(kargs[karg]) + "\n" + ) + if kargs_pos: + new_function.__doc__ += "keyword argument positions:\n" + for karg in kargs_pos: + new_function.__doc__ += ( + " " + str(karg) + " is at pos " + str(kargs_pos[karg]) + "\n" + ) + if len(args) or len(kargs): + new_function.__doc__ += ( + "(defaults have been set by default_arguments_decorator method)" + ) return new_function + def return_instance_if_value_has_it(value): """Return value.instance if value is an instance of ClassFromFunctions, else return value From 5833c5afcbce5a60bf65291bc52407be1508913a Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 18:24:33 +0200 Subject: [PATCH 08/17] add unittests for function_class add tests for some existing function_class functionality. Add tests for the keyword argument changes. --- bindings/python/tests/CMakeLists.txt | 3 +- bindings/python/tests/runTests.py.in | 1 + bindings/python/tests/test_function_class.py | 177 +++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 bindings/python/tests/test_function_class.py diff --git a/bindings/python/tests/CMakeLists.txt b/bindings/python/tests/CMakeLists.txt index e118e5a6cb..f583e1ce8b 100644 --- a/bindings/python/tests/CMakeLists.txt +++ b/bindings/python/tests/CMakeLists.txt @@ -25,6 +25,7 @@ set(test_python_bindings_DATA test_session.py test_split.py test_transaction.py - test_query.py) + test_query.py + test_function_class.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 8f88d8eb68..5b9142592d 100755 --- a/bindings/python/tests/runTests.py.in +++ b/bindings/python/tests/runTests.py.in @@ -5,6 +5,7 @@ import os os.environ["GNC_UNINSTALLED"] = "1" +from test_function_class import TestFunctionClass from test_gettext import TestGettext from test_session import TestSession from test_book import TestBook diff --git a/bindings/python/tests/test_function_class.py b/bindings/python/tests/test_function_class.py new file mode 100644 index 0000000000..df099e086b --- /dev/null +++ b/bindings/python/tests/test_function_class.py @@ -0,0 +1,177 @@ +# test cases for function_class.py +# +# @date 2020-06-18 +# @author Christoph Holtermann + +import sys +from unittest import TestCase, main +from gnucash.function_class import ClassFromFunctions, default_arguments_decorator + + +class Instance: + """instance class for ClassFromFunction tests""" + + pass + + +def prefix_new_function(): + """new function for ClassFromFunction tests + + returns instance of Instance class""" + return Instance() + + +def prefix_test_function(self): + """test function for ClassFromFunction tests""" + return True + + +def prefix_test_function_return_args(self, *args, **kargs): + return self, args, kargs + + +b_default = "b default value" + + +def prefix_test_function_return_arg_karg(self, a, b=b_default): + return {"self": self, "a": a, "b": b} + + +def other_function(self, arg=None): + return self, arg + + +class TestClass(ClassFromFunctions): + _module = sys.modules[__name__] + + pass + + +class TestFunctionClass(TestCase): + def test_add_constructor_and_methods_with_prefix(self): + TestClass.add_constructor_and_methods_with_prefix("prefix_", "new_function") + self.TestClass = TestClass + self.testClass = TestClass() + self.assertIsInstance(self.testClass.instance, Instance) + self.assertTrue(self.testClass.test_function()) + + def test_add_method(self): + """test if add_method adds method and if in case of FunctionClass + Instance instances get returned instead of FunctionClass instances""" + TestClass.add_method("other_function", "other_method") + self.t = TestClass() + obj, arg = self.t.other_method() + self.assertIsInstance(obj, Instance) + obj, arg = self.t.other_method(self.t) + self.assertIsInstance(arg, Instance) + obj, arg = self.t.other_method(arg=self.t) + self.assertIsInstance(arg, Instance) + + def test_ya_add_method(self): + """test if ya_add_method adds method and if in case of FunctionClass + Instance instances get returned instead of FunctionClass instances + with the exception of self (first) argument""" + TestClass.ya_add_method("other_function", "other_method") + self.t = TestClass() + obj, arg = self.t.other_method() + self.assertIsInstance(obj, TestClass) + obj, arg = self.t.other_method(self.t) + self.assertIsInstance(arg, Instance) + obj, arg = self.t.other_method(arg=self.t) + self.assertIsInstance(arg, Instance) + + def test_default_arguments_decorator(self): + """test default_arguments_decorator()""" + TestClass.backup_test_function_return_args = TestClass.test_function_return_args + TestClass.backup_test_function_return_arg_karg = ( + TestClass.test_function_return_arg_karg + ) + self.t = TestClass() + + arg1 = "arg1" + arg2 = "arg2" + arg3 = {"arg3": arg2} + arg4 = 4 + TestClass.decorate_method( + default_arguments_decorator, "test_function_return_args", arg1, arg2 + ) + self.assertEqual( + self.t.test_function_return_args(), (self.t.instance, (arg2,), {}) + ) # default arg1 gets overwritten by class instances instance attribute + self.assertEqual( + self.t.test_function_return_args(arg3), (self.t.instance, (arg3,), {}) + ) + self.assertEqual( + self.t.test_function_return_args(arg1, arg3), + (self.t.instance, (arg1, arg3), {}), + ) + self.assertEqual( + self.t.test_function_return_args(arg1, arg3, arg4=arg4), + (self.t.instance, (arg1, arg3), {"arg4": arg4}), + ) + + TestClass.test_function_return_args = TestClass.backup_test_function_return_args + TestClass.decorate_method( + default_arguments_decorator, + "test_function_return_args", + arg1, + arg2, + arg4=arg4, + ) + self.assertEqual( + self.t.test_function_return_args(), + (self.t.instance, (arg2,), {"arg4": arg4}), + ) + self.assertEqual( + self.t.test_function_return_args(arg1, arg3, arg4=arg2), + (self.t.instance, (arg1, arg3), {"arg4": arg2}), + ) + + with self.assertRaises(TypeError): + # should fail because a is set both as a positional and as a keyword argument + TestClass.decorate_method( + default_arguments_decorator, + "test_function_return_arg_karg", + None, + arg1, + a=arg2, + kargs_pos={"a": 1, "b": 2}, + ) + TestClass.decorate_method( + default_arguments_decorator, + "test_function_return_arg_karg", + None, + a=arg1, + kargs_pos={"a": 1, "b": 2}, + ) + self.assertEqual( + self.t.test_function_return_arg_karg(), + {"self": self.t.instance, "a": arg1, "b": b_default}, + ) + + TestClass.test_function_return_arg_karg = ( + TestClass.backup_test_function_return_arg_karg + ) + TestClass.decorate_method( + default_arguments_decorator, + "test_function_return_arg_karg", + None, + arg1, + kargs_pos={"a": 1, "b": 2}, + ) + self.assertEqual( + self.t.test_function_return_arg_karg(), + {"self": self.t.instance, "a": arg1, "b": b_default}, + ) + self.assertEqual( + self.t.test_function_return_arg_karg(arg2), + {"self": self.t.instance, "a": arg2, "b": b_default}, + ) + self.assertEqual( + self.t.test_function_return_arg_karg(arg2, arg3), + {"self": self.t.instance, "a": arg2, "b": arg3}, + ) + + +if __name__ == "__main__": + main() From 44e61f4df27c972c62218dea2069a43e92819ea5 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 19:15:51 +0200 Subject: [PATCH 09/17] enable Session.__init__() to be provided with existing instance or book --- bindings/python/gnucash_core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index 32c33bca32..360c71ceda 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -210,7 +210,12 @@ class Session(GnuCashCoreClass): you don't need to cleanup and call end() and destroy(), that is handled for you, and the exception is raised. """ - GnuCashCoreClass.__init__(self, Book()) + if instance is not None: + GnuCashCoreClass.__init__(self, instance=instance) + else: + if book is None: + book = Book() + GnuCashCoreClass.__init__(self, book) if book_uri is not None: try: From 485d8a65b0ad4d6e7e3e52de991fdf99135ff088 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 19:17:09 +0200 Subject: [PATCH 10/17] decorate Session.begin with default mode argument --- bindings/python/gnucash_core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index 360c71ceda..f5647c0121 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -627,7 +627,9 @@ Session.decorate_functions(one_arg_default_none, "load", "save") Session.decorate_functions( Session.raise_backend_errors_after_call, "begin", "load", "save", "end") +Session.decorate_method(default_arguments_decorator, "begin", None, mode=SessionOpenMode.SESSION_NORMAL_OPEN) Session.decorate_functions(deprecated_args_session_begin, "begin") + Session.get_book = method_function_returns_instance( Session.get_book, Book ) From 0434acbe1035ed679d23242a412c48a680ac5a07 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Fri, 19 Jun 2020 20:45:21 +0200 Subject: [PATCH 11/17] reformat two python example scripts with black use black python code formatter on latex_invoices.py and gncinvoice_jinja.py --- .../example_scripts/gncinvoice_jinja.py | 61 +++--- .../python/example_scripts/latex_invoices.py | 198 ++++++++++-------- 2 files changed, 143 insertions(+), 116 deletions(-) diff --git a/bindings/python/example_scripts/gncinvoice_jinja.py b/bindings/python/example_scripts/gncinvoice_jinja.py index bfa7c09a58..f13675a690 100755 --- a/bindings/python/example_scripts/gncinvoice_jinja.py +++ b/bindings/python/example_scripts/gncinvoice_jinja.py @@ -45,10 +45,12 @@ except ImportError as import_error: print(import_error) sys.exit(2) + class Usage(Exception): def __init__(self, msg): self.msg = msg + def main(argv=None): if argv is None: argv = sys.argv @@ -69,27 +71,27 @@ def main(argv=None): try: opts, args = getopt.getopt(argv[1:], "fhliI:t:o:OP:", ["help"]) except getopt.error as msg: - raise Usage(msg) + raise Usage(msg) for opt in opts: if opt[0] in ["-f"]: print("ignoring lock") ignore_lock = True - if opt[0] in ["-h","--help"]: + if opt[0] in ["-h", "--help"]: raise Usage("Help:") if opt[0] in ["-I"]: invoice_id = opt[1] - print ("using invoice ID '" + str(invoice_id) + "'.") + print("using invoice ID '" + str(invoice_id) + "'.") if opt[0] in ["-i"]: - print ("Using ipshell") + print("Using ipshell") with_ipshell = True if opt[0] in ["-o"]: filename_output = opt[1] print("using output file", filename_output) if opt[0] in ["-O"]: if filename_output: - print ("given output filename will be overwritten,") - print ("creating output filename from Invoice data.") + print("given output filename will be overwritten,") + print("creating output filename from Invoice data.") filename_from_invoice = True if opt[0] in ["-t"]: filename_template = opt[1] @@ -99,13 +101,13 @@ def main(argv=None): print("listing invoices") if opt[0] in ["-P"]: output_path = opt[1] - print ("output path is", output_path + ".") + print("output path is", output_path + ".") # Check for correct input - if len(args)>1: - print("opts:",opts,"args:",args) + if len(args) > 1: + print("opts:", opts, "args:", args) raise Usage("Only one input possible !") - if len(args)==0: + if len(args) == 0: raise Usage("No input given !") input_url = args[0] @@ -123,16 +125,16 @@ def main(argv=None): except Usage as err: if err.msg == "Help:": - retcode=0 + retcode = 0 else: print("Error:", err.msg, file=sys.stderr) print("for help use --help", file=sys.stderr) - retcode=2 + retcode = 2 print() print("Usage:") print() - print("Invoke with",prog_name,"gnucash_url.") + print("Invoke with", prog_name, "gnucash_url.") print("where input is") print(" filename") print("or file://filename") @@ -173,9 +175,9 @@ def main(argv=None): invoice_list = get_all_invoices(book) if list_invoices: - for number,invoice in enumerate(invoice_list): - print(str(number)+")") - print(invoice) + for number, invoice in enumerate(invoice_list): + print(str(number) + ")") + print(invoice) if not (no_output): @@ -191,7 +193,6 @@ def main(argv=None): print("Using the following invoice:") print(invoice) - path_template = os.path.dirname(filename_template) filename_template_basename = os.path.basename(filename_template) @@ -199,25 +200,37 @@ def main(argv=None): env = jinja2.Environment(loader=loader) template = env.get_template(filename_template_basename) - #company = gnucash_business.Company(book.instance) + # company = gnucash_business.Company(book.instance) - output = template.render(invoice=invoice, locale=locale) #, company=company) + output = template.render(invoice=invoice, locale=locale) # , company=company) if filename_from_invoice: - filename_date = invoice.GetDatePosted().strftime("%Y-%m-%d") # something like 2014-11-01 + filename_date = invoice.GetDatePosted().strftime( + "%Y-%m-%d" + ) # something like 2014-11-01 filename_owner_name = str(invoice.GetOwner().GetName()) filename_invoice_id = str(invoice.GetID()) - filename_output = filename_date + "_" + filename_owner_name + "_" + filename_invoice_id + ".tex" + filename_output = ( + filename_date + + "_" + + filename_owner_name + + "_" + + filename_invoice_id + + ".tex" + ) if output_path: - filename_output = os.path.join(output_path, os.path.basename(filename_output)) + filename_output = os.path.join( + output_path, os.path.basename(filename_output) + ) - print ("Writing output", filename_output, ".") - with open(filename_output, 'w') as f: + print("Writing output", filename_output, ".") + with open(filename_output, "w") as f: f.write(output) if with_ipshell: import IPython + IPython.embed() diff --git a/bindings/python/example_scripts/latex_invoices.py b/bindings/python/example_scripts/latex_invoices.py index c29922e05d..829021adfc 100644 --- a/bindings/python/example_scripts/latex_invoices.py +++ b/bindings/python/example_scripts/latex_invoices.py @@ -57,13 +57,24 @@ try: import str_methods from gncinvoicefkt import * from IPython import version_info as IPython_version_info - if IPython_version_info[0]>=1: + + if IPython_version_info[0] >= 1: from IPython.terminal.ipapp import TerminalIPythonApp else: from IPython.frontend.terminal.ipapp import TerminalIPythonApp - from gnucash.gnucash_business import Customer, Employee, Vendor, Job, \ - Address, Invoice, Entry, TaxTable, TaxTableEntry, GNC_AMT_TYPE_PERCENT, \ - GNC_DISC_PRETAX + from gnucash.gnucash_business import ( + Customer, + Employee, + Vendor, + Job, + Address, + Invoice, + Entry, + TaxTable, + TaxTableEntry, + GNC_AMT_TYPE_PERCENT, + GNC_DISC_PRETAX, + ) from gnucash import SessionOpenMode import locale except ImportError as import_error: @@ -71,99 +82,102 @@ except ImportError as import_error: print(import_error) sys.exit(2) + class Usage(Exception): def __init__(self, msg): self.msg = msg + def invoice_to_lco(invoice): - """returns a string which forms a lco-file for use with LaTeX""" + """returns a string which forms a lco-file for use with LaTeX""" - lco_out=u"\ProvidesFile{data.lco}[]\n" + lco_out = u"\ProvidesFile{data.lco}[]\n" - def write_variable(ukey, uvalue, replace_linebreak=True): + def write_variable(ukey, uvalue, replace_linebreak=True): - outstr = u"" - if uvalue.endswith("\n"): - uvalue=uvalue[0:len(uvalue)-1] + outstr = u"" + if uvalue.endswith("\n"): + uvalue = uvalue[0 : len(uvalue) - 1] - if not ukey in [u"fromaddress",u"toaddress",u"date"]: - outstr += u'\\newkomavar{' + if not ukey in [u"fromaddress", u"toaddress", u"date"]: + outstr += u"\\newkomavar{" + outstr += ukey + outstr += u"}\n" + + outstr += u"\\setkomavar{" outstr += ukey - outstr += u"}\n" + outstr += u"}{" + if replace_linebreak: + outstr += uvalue.replace(u"\n", u"\\\\") + "}" + return outstr - outstr += u"\\setkomavar{" - outstr += ukey - outstr += u"}{" - if replace_linebreak: - outstr += uvalue.replace(u"\n",u"\\\\")+"}" - return outstr + # Write owners address + add_str = u"" + owner = invoice.GetOwner() + if owner.GetName() != "": + add_str += owner.GetName().decode("UTF-8") + "\n" - # Write owners address - add_str=u"" - owner = invoice.GetOwner() - if owner.GetName() != "": - add_str += owner.GetName().decode("UTF-8")+"\n" + addr = owner.GetAddr() + if addr.GetName() != "": + add_str += addr.GetName().decode("UTF-8") + "\n" + if addr.GetAddr1() != "": + add_str += addr.GetAddr1().decode("UTF-8") + "\n" + if addr.GetAddr2() != "": + add_str += addr.GetAddr2().decode("UTF-8") + "\n" + if addr.GetAddr3() != "": + add_str += addr.GetAddr3().decode("UTF-8") + "\n" + if addr.GetAddr4() != "": + add_str += addr.GetAddr4().decode("UTF-8") + "\n" - addr = owner.GetAddr() - if addr.GetName() != "": - add_str += addr.GetName().decode("UTF-8")+"\n" - if addr.GetAddr1() != "": - add_str += addr.GetAddr1().decode("UTF-8")+"\n" - if addr.GetAddr2() != "": - add_str += addr.GetAddr2().decode("UTF-8")+"\n" - if addr.GetAddr3() != "": - add_str += addr.GetAddr3().decode("UTF-8")+"\n" - if addr.GetAddr4() != "": - add_str += addr.GetAddr4().decode("UTF-8")+"\n" + lco_out += write_variable("toaddress2", add_str) - lco_out += write_variable("toaddress2",add_str) + # Invoice number + inr_str = invoice.GetID() + lco_out += write_variable("rechnungsnummer", inr_str) - # Invoice number - inr_str = invoice.GetID() - lco_out += write_variable("rechnungsnummer",inr_str) + # date + date = invoice.GetDatePosted() + udate = date.strftime("%d.%m.%Y") + lco_out += write_variable("date", udate) + "\n" - # date - date = invoice.GetDatePosted() - udate = date.strftime("%d.%m.%Y") - lco_out += write_variable("date",udate)+"\n" + # date due + date_due = invoice.GetDateDue() + udate_due = date_due.strftime("%d.%m.%Y") + lco_out += write_variable("date_due", udate_due) + "\n" - # date due - date_due = invoice.GetDateDue() - udate_due = date_due.strftime("%d.%m.%Y") - lco_out += write_variable("date_due",udate_due)+"\n" + # Write the entries + ent_str = u"" + locale.setlocale(locale.LC_ALL, "de_DE") + for n, ent in enumerate(invoice.GetEntries()): + line_str = u"" - # Write the entries - ent_str = u"" - locale.setlocale(locale.LC_ALL,"de_DE") - for n,ent in enumerate(invoice.GetEntries()): + if type(ent) != Entry: + ent = Entry(instance=ent) # Add to method_returns_list - line_str = u"" + descr = ent.GetDescription() + price = ent.GetInvPrice().to_double() + n = ent.GetQuantity() - if type(ent) != Entry: - ent=Entry(instance=ent) # Add to method_returns_list + uprice = locale.currency(price).rstrip(" EUR") + un = unicode( + int(float(n.num()) / n.denom()) + ) # choose best way to format numbers according to locale - descr = ent.GetDescription() - price = ent.GetInvPrice().to_double() - n = ent.GetQuantity() + line_str = u"\Artikel{" + line_str += un + line_str += u"}{" + line_str += descr.decode("UTF-8") + line_str += u"}{" + line_str += uprice + line_str += u"}" - uprice = locale.currency(price).rstrip(" EUR") - un = unicode(int(float(n.num())/n.denom())) # choose best way to format numbers according to locale + # print(line_str) + ent_str += line_str - line_str = u"\Artikel{" - line_str += un - line_str += u"}{" - line_str += descr.decode("UTF-8") - line_str += u"}{" - line_str += uprice - line_str += u"}" + lco_out += write_variable("entries", ent_str) - #print(line_str) - ent_str += line_str - - lco_out += write_variable("entries",ent_str) - - return lco_out + return lco_out def main(argv=None): @@ -181,20 +195,20 @@ def main(argv=None): try: opts, args = getopt.getopt(argv[1:], "fhiln:po:", ["help"]) except getopt.error as msg: - raise Usage(msg) + raise Usage(msg) for opt in opts: if opt[0] in ["-f"]: print("ignoring lock") ignore_lock = True - if opt[0] in ["-h","--help"]: + if opt[0] in ["-h", "--help"]: raise Usage("Help:") if opt[0] in ["-i"]: print("Using ipshell") with_ipshell = True if opt[0] in ["-l"]: print("listing all invoices") - list_invoices=True + list_invoices = True if opt[0] in ["-n"]: invoice_number = int(opt[1]) print("using invoice number", invoice_number) @@ -202,25 +216,25 @@ def main(argv=None): if opt[0] in ["-o"]: output_file_name = opt[1] print("using output file", output_file_name) - if len(args)>1: - print("opts:",opts,"args:",args) + if len(args) > 1: + print("opts:", opts, "args:", args) raise Usage("Only one input can be accepted !") - if len(args)==0: + if len(args) == 0: raise Usage("No input given !") input_url = args[0] except Usage as err: if err.msg == "Help:": - retcode=0 + retcode = 0 else: print("Error:", err.msg, file=sys.stderr) print("for help use --help", file=sys.stderr) - retcode=2 + retcode = 2 print("Generate a LaTeX invoice or print out all invoices.") print() print("Usage:") print() - print("Invoke with",prog_name,"input.") + print("Invoke with", prog_name, "input.") print("where input is") print(" filename") print("or file://filename") @@ -253,39 +267,39 @@ def main(argv=None): comm_table = book.get_table() EUR = comm_table.lookup("CURRENCY", "EUR") - invoice_list=get_all_invoices(book) + invoice_list = get_all_invoices(book) if list_invoices: - for number,invoice in enumerate(invoice_list): - print(str(number)+")") + for number, invoice in enumerate(invoice_list): + print(str(number) + ")") print(invoice) if not (no_latex_output): if invoice_number == None: print("Using the first invoice:") - invoice_number=0 + invoice_number = 0 - invoice=invoice_list[invoice_number] + invoice = invoice_list[invoice_number] print("Using the following invoice:") print(invoice) - lco_str=invoice_to_lco(invoice) + lco_str = invoice_to_lco(invoice) # Opening output file - f=open(output_file_name,"w") - lco_str=lco_str.encode("latin1") + f = open(output_file_name, "w") + lco_str = lco_str.encode("latin1") f.write(lco_str) f.close() if with_ipshell: app = TerminalIPythonApp.instance() - app.initialize(argv=[]) # argv=[] instructs IPython to ignore sys.argv + app.initialize(argv=[]) # argv=[] instructs IPython to ignore sys.argv app.start() - #session.save() + # session.save() session.end() + if __name__ == "__main__": sys.exit(main()) - From b9c6fc28767c130c7bc52f68c3dbaee88fe77f41 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Thu, 11 Jun 2020 17:52:02 +0200 Subject: [PATCH 12/17] add some unittests for python Session test arguments, deprecated as well as new mode arguments test creating a session with a new xml file using __init__() and begin(). Test raising exception when opening nonexistent file without respective mode setting. --- bindings/python/tests/test_session.py | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/bindings/python/tests/test_session.py b/bindings/python/tests/test_session.py index 7751e37e66..8127e2e6a1 100644 --- a/bindings/python/tests/test_session.py +++ b/bindings/python/tests/test_session.py @@ -10,12 +10,57 @@ from unittest import TestCase, main -from gnucash import Session +from gnucash import ( + Session, + SessionOpenMode +) + +from gnucash.gnucash_core import GnuCashBackendException class TestSession(TestCase): def test_create_empty_session(self): self.ses = Session() + def test_session_deprecated_arguments(self): + """use deprecated arguments ignore_lock, is_new, force_new""" + self.ses = Session(ignore_lock=False, is_new=True, force_new=False) + + def test_session_mode(self): + """use mode argument""" + self.ses = Session(mode=SessionOpenMode.SESSION_NORMAL_OPEN) + + def test_session_with_new_file(self): + """create Session with new xml file""" + from tempfile import TemporaryDirectory + from urllib.parse import urlunparse + with TemporaryDirectory() as tempdir: + uri = urlunparse(("xml", tempdir, "tempfile", "", "", "")) + with Session(uri, SessionOpenMode.SESSION_NEW_STORE) as ses: + pass + + # try to open nonexistent file without NEW mode - should raise Exception + uri = urlunparse(("xml", tempdir, "tempfile2", "", "", "")) + with Session() as ses: + with self.assertRaises(GnuCashBackendException): + ses.begin(uri, mode=SessionOpenMode.SESSION_NORMAL_OPEN) + + # try to open nonexistent file without NEW mode - should raise Exception + # use deprecated arg is_new + uri = urlunparse(("xml", tempdir, "tempfile2", "", "", "")) + with Session() as ses: + with self.assertRaises(GnuCashBackendException): + ses.begin(uri, is_new=False) + + uri = urlunparse(("xml", tempdir, "tempfile3", "", "", "")) + with Session() as ses: + ses.begin(uri, mode=SessionOpenMode.SESSION_NEW_STORE) + + # test using deprecated args + uri = urlunparse(("xml", tempdir, "tempfile4", "", "", "")) + with Session() as ses: + ses.begin(uri, is_new=True) + + def test_app_utils_get_current_session(self): from gnucash import _sw_app_utils self.ses_instance = _sw_app_utils.gnc_get_current_session() From 3e842a7bf6e3b5479c9e110554c0399b461373a6 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Sat, 20 Jun 2020 10:35:31 +0200 Subject: [PATCH 13/17] use urllib.parse.urlparse to check for xml on python Session init --- bindings/python/gnucash_core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index f5647c0121..2ef37911d4 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -29,6 +29,8 @@ # @ingroup python_bindings from enum import IntEnum +from urllib.parse import urlparse + from gnucash import gnucash_core_c from gnucash import _sw_core_utils @@ -228,7 +230,8 @@ class Session(GnuCashCoreClass): # Any existing store obviously has to be loaded # More background: https://bugs.gnucash.org/show_bug.cgi?id=726891 is_new = mode in (SessionOpenMode.SESSION_NEW_STORE, SessionOpenMode.SESSION_NEW_OVERWRITE) - if book_uri[:3] != "xml" or not is_new: + scheme = urlparse(book_uri).scheme + if not (is_new and scheme == 'xml'): self.load() except GnuCashBackendException as backend_exception: self.end() From 7c8e0a28fc7ef0be313a6e92910fbd9e691fa204 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Sat, 20 Jun 2020 13:21:41 +0200 Subject: [PATCH 14/17] better display for doxygen, typo and consistent naming --- libgnucash/engine/qofsession.h | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libgnucash/engine/qofsession.h b/libgnucash/engine/qofsession.h index 5836d879c6..7816229529 100644 --- a/libgnucash/engine/qofsession.h +++ b/libgnucash/engine/qofsession.h @@ -154,24 +154,28 @@ void qof_session_swap_data (QofSession *session_1, QofSession *session_2); * assumed. Customized backends can choose to search other * application-specific directories or URI schemes as well. * - * @param mode The SessionMode. + * @param mode The SessionOpenMode. * - * ==== SessionMode ==== - * `SESSION_NORMAL`: Find an existing file or database at the provided uri and + * @par ==== SessionOpenMode ==== + * `SESSION_NORMAL_OPEN`: Find an existing file or database at the provided uri and * open it if it is unlocked. If it is locked post a QOF_BACKEND_LOCKED error. + * @par * `SESSION_NEW_STORE`: Check for an existing file or database at the provided * uri and if none is found, create it. If the file or database exists post a * QOF_BACKED_STORE_EXISTS and return. + * @par * `SESSION_READ_ONLY`: Find an existing file or database and open it without * disturbing the lock if it exists or setting one if not. This will also set a * flag on the book that will prevent many elements from being edited and will * prevent the backend from saving any edits. + * @par * `SESSION_OVERWRITE`: Create a new file or database at the provided uri, * deleting any existing file or database. - * `SESSION_BREAK_LOCK1: Find an existing file or database, lock it, and open + * @par + * `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open * it. If there is already a lock replace it with a new one for this session. * - * ==== Errors ==== + * @par ==== Errors ==== * This function signals failure by queuing errors. After it completes use * qof_session_get_error() and test that the value is `ERROR_BACKEND_NONE` to * determine that the session began successfully. From e23bf0bc1c7e35c66c7be2d8250e7f6073eb3b8b Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Sat, 4 Jul 2020 22:16:13 +0200 Subject: [PATCH 15/17] fix SessionOpenMode explanation for SESSION_NEW_OVERWRITE --- libgnucash/engine/qofsession.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libgnucash/engine/qofsession.h b/libgnucash/engine/qofsession.h index 7816229529..4bab7f0fd9 100644 --- a/libgnucash/engine/qofsession.h +++ b/libgnucash/engine/qofsession.h @@ -169,7 +169,7 @@ void qof_session_swap_data (QofSession *session_1, QofSession *session_2); * flag on the book that will prevent many elements from being edited and will * prevent the backend from saving any edits. * @par - * `SESSION_OVERWRITE`: Create a new file or database at the provided uri, + * `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, * deleting any existing file or database. * @par * `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open From 40cfb70fb722501278d87bc283588d70a578b583 Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Sat, 4 Jul 2020 22:22:16 +0200 Subject: [PATCH 16/17] fix SessionOpenMode explanation for SESSION_NORMAL_OPEN --- bindings/python/gnucash_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index 2ef37911d4..72837ba141 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -184,7 +184,7 @@ class Session(GnuCashCoreClass): @note SessionOpenMode replaces deprecated ignore_lock, is_new and force_new. @par SessionOpenMode - `SESSION_NORMAL`: Find an existing file or database at the provided uri and + `SESSION_NORMAL_OPEN`: Find an existing file or database at the provided uri and open it if it is unlocked. If it is locked post a QOF_BACKEND_LOCKED error. @par `SESSION_NEW_STORE`: Check for an existing file or database at the provided @@ -196,7 +196,7 @@ class Session(GnuCashCoreClass): flag on the book that will prevent many elements from being edited and will prevent the backend from saving any edits. @par - `SESSION_OVERWRITE`: Create a new file or database at the provided uri, + `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, deleting any existing file or database. @par `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open From 22f91c407ee52fcba649d2f608f900a7be6f99fc Mon Sep 17 00:00:00 2001 From: c-holtermann Date: Sat, 4 Jul 2020 22:26:35 +0200 Subject: [PATCH 17/17] use same order in comment as in definition of SessionOpenMode enum --- bindings/python/gnucash_core.py | 6 +++--- libgnucash/engine/qofsession.h | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/python/gnucash_core.py b/bindings/python/gnucash_core.py index 72837ba141..50eb6c41a0 100644 --- a/bindings/python/gnucash_core.py +++ b/bindings/python/gnucash_core.py @@ -191,14 +191,14 @@ class Session(GnuCashCoreClass): uri and if none is found, create it. If the file or database exists post a QOF_BACKED_STORE_EXISTS and return. @par + `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, + deleting any existing file or database. + @par `SESSION_READ_ONLY`: Find an existing file or database and open it without disturbing the lock if it exists or setting one if not. This will also set a flag on the book that will prevent many elements from being edited and will prevent the backend from saving any edits. @par - `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, - deleting any existing file or database. - @par `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open it. If there is already a lock replace it with a new one for this session. diff --git a/libgnucash/engine/qofsession.h b/libgnucash/engine/qofsession.h index 4bab7f0fd9..e01d95a653 100644 --- a/libgnucash/engine/qofsession.h +++ b/libgnucash/engine/qofsession.h @@ -164,14 +164,14 @@ void qof_session_swap_data (QofSession *session_1, QofSession *session_2); * uri and if none is found, create it. If the file or database exists post a * QOF_BACKED_STORE_EXISTS and return. * @par + * `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, + * deleting any existing file or database. + * @par * `SESSION_READ_ONLY`: Find an existing file or database and open it without * disturbing the lock if it exists or setting one if not. This will also set a * flag on the book that will prevent many elements from being edited and will * prevent the backend from saving any edits. * @par - * `SESSION_NEW_OVERWRITE`: Create a new file or database at the provided uri, - * deleting any existing file or database. - * @par * `SESSION_BREAK_LOCK`: Find an existing file or database, lock it, and open * it. If there is already a lock replace it with a new one for this session. *