diff --git a/gnucash/CMakeLists.txt b/gnucash/CMakeLists.txt index 28e7d7d472..f7e4c21ea5 100644 --- a/gnucash/CMakeLists.txt +++ b/gnucash/CMakeLists.txt @@ -111,6 +111,7 @@ target_link_libraries (gnucash gnc-bi-import gnc-customer-import gnc-report PkgConfig::GTK3 ${GUILE_LDFLAGS} PkgConfig::GLIB2 ${Boost_LIBRARIES} + ${Python3_LIBRARIES} ) set(gnucash_cli_SOURCES @@ -137,15 +138,20 @@ endif() add_dependencies (gnucash-cli gnucash) -target_compile_definitions(gnucash-cli PRIVATE -DG_LOG_DOMAIN=\"gnc.bin\") +target_compile_definitions(gnucash-cli PRIVATE + -DG_LOG_DOMAIN=\"gnc.bin\" + $<$:HAVE_PYTHON_H>) target_link_libraries (gnucash-cli gnc-app-utils gnc-engine gnc-core-utils gnucash-guile gnc-report ${GUILE_LDFLAGS} PkgConfig::GLIB2 ${Boost_LIBRARIES} + ${Python3_LIBRARIES} ) +target_include_directories (gnucash-cli PRIVATE ${Python3_INCLUDE_DIRS}) + if (BUILDING_FROM_VCS) target_compile_definitions(gnucash PRIVATE -DGNC_VCS=\"git\") target_compile_definitions(gnucash-cli PRIVATE -DGNC_VCS=\"git\") diff --git a/gnucash/gnucash-cli.cpp b/gnucash/gnucash-cli.cpp index 23a8475fc9..e757ef50fd 100644 --- a/gnucash/gnucash-cli.cpp +++ b/gnucash/gnucash-cli.cpp @@ -62,6 +62,12 @@ namespace Gnucash { boost::optional m_namespace; bool m_verbose = false; + boost::optional m_script; + std::vector m_script_args; + std::string m_language; + bool m_interactive; + bool m_open_readwrite; + boost::optional m_report_cmd; boost::optional m_report_name; boost::optional m_export_type; @@ -107,6 +113,17 @@ Gnucash::GnucashCli::configure_program_options (void) m_opt_desc_display->add (quotes_options); m_opt_desc_all.add (quotes_options); + bpo::options_description cli_options(_("Scripting and/or Interactive Session Options")); + cli_options.add_options() + ("script,S", bpo::value (&m_script), _("Script to run")) + ("script-args", bpo::value (&m_script_args), _("Script arguments")) + ("interactive,I", bpo::bool_switch (&m_interactive), _("Interactive session")) + ("language,L", bpo::value (&m_language)->default_value("guile"), _("Specify language for script or interactive session; guile (default) or python")) + ("readwrite,W", bpo::bool_switch (&m_open_readwrite), _("Open datafile read-write for script and/or interactive session")); + m_pos_opt_desc.add("script-args", -1); + m_opt_desc_display->add (cli_options); + m_opt_desc_all.add (cli_options); + bpo::options_description report_options(_("Report Generation Options")); report_options.add_options() ("report,R", bpo::value (&m_report_cmd), @@ -127,10 +144,32 @@ may be specified to describe some saved options.\n" } int -Gnucash::GnucashCli::start ([[maybe_unused]] int argc, [[maybe_unused]] char **argv) +Gnucash::GnucashCli::start (int argc, char **argv) { Gnucash::CoreApp::start(); + if (m_interactive || m_script) + { + std::vector newArgv = + { argc ? argv[0] : "", m_file_to_load ? m_file_to_load->c_str() : ""}; + std::transform (m_script_args.begin(), m_script_args.end(), std::back_inserter(newArgv), + [](const std::string& s) { return s.c_str(); }); + // note the vector is valid as long as script_args's strings are not damaged! + + std::cout << "\n\nScript args:"; + for (const auto& arg : newArgv) + std::cout << ' ' << arg; + std::cout << '\n'; + std::cout << "File to load: " << (m_file_to_load ? *m_file_to_load : "(null)") << std::endl; + std::cout << "Language: " << m_language << std::endl; + std::cout << "Script: " << (m_script ? *m_script : "(null)") << std::endl; + std::cout << "Readwrite: " << (m_open_readwrite ? 'Y' : 'N') << std::endl; + std::cout << "Interactive: " << (m_interactive ? 'Y' : 'N') << "\n\n" << std::endl; + + return Gnucash::run_scripting (newArgv, m_file_to_load, m_language, m_script, + m_open_readwrite, m_interactive); + } + if (!m_quotes_cmd.empty()) { if (m_quotes_cmd.front() == "info") diff --git a/gnucash/gnucash-commands.cpp b/gnucash/gnucash-commands.cpp index cea689679e..402d874ddc 100644 --- a/gnucash/gnucash-commands.cpp +++ b/gnucash/gnucash-commands.cpp @@ -31,6 +31,8 @@ #include "gnucash-commands.hpp" #include "gnucash-core-app.hpp" +#include "gnc-ui-util.h" +#include "gnc-path.h" #include #include @@ -40,12 +42,17 @@ #include #include +#include #include #include #include #include #include +#ifdef HAVE_PYTHON_H +#include +#endif + namespace bl = boost::locale; static std::string empty_string{}; @@ -112,12 +119,48 @@ get_line (const char* prompt) static std::string get_choice (const char* prompt, const std::vector choices) { - while (true) + std::string response; + do { - auto response = get_line (prompt); - if (std::find (choices.begin(), choices.end(), response) != choices.end()) - return response; + response = get_line (prompt); } + while (std::none_of (choices.begin(), choices.end(), + [&response](auto& choice) { return choice == response; } )); + return response; +} + +struct scripting_args +{ + const boost::optional& script; + bool interactive; +}; + +// when guile or python script/interactive session succeed +static void +cleanup_and_exit_with_save () +{ + if (gnc_current_session_exist()) + { + auto session = gnc_get_current_session(); + auto book = gnc_get_current_book(); + auto is_yes = [] (const char* prompt) + { + auto s = get_choice (prompt, {"Y","y","N","n"}); + return s == "Y" || s == "y"; + }; + + std::cerr << _("Warning: session was not cleared.") << std::endl; + + if (!qof_book_session_not_saved (book)) + std::cerr << _("Please don't forget to clear session before shutdown.") << std::endl; + else if (qof_book_is_readonly (book)) + std::cerr << _("Book is readonly. Unsaved changes will be lost.") << std::endl; + else if (is_yes (_("There are unsaved changes. Save before clearing [YN]?"))) + qof_session_save (session, report_session_percentage); + + gnc_clear_current_session (); + } + gnc_shutdown_cli (0); } /* Don't try to use std::string& for the members of the following struct, it @@ -469,3 +512,147 @@ Gnucash::report_list (void) scm_boot_guile (0, nullptr, scm_report_list, NULL); return 0; } + +// scripting code follows: + +static void +run_guile_cli (void *data, [[maybe_unused]] int argc, [[maybe_unused]] char **argv) +{ + auto args = static_cast(data); + if (args->script) + { + PINFO ("Running script from %s... ", args->script->c_str()); + scm_c_primitive_load (args->script->c_str()); + } + if (args->interactive) + { + std::cout << _("Welcome to Gnucash Interactive Guile Session") << std::endl; + std::vector modules = + { "gnucash core-utils", "gnucash engine", "gnucash app-utils", "gnucash report", + "system repl repl", "ice-9 readline" }; + auto show_and_load = [](const auto& mod) + { + std::cout << bl::format ("(use-modules ({1}))") % mod << std::endl; + scm_c_use_module (mod); + }; + std::for_each (modules.begin(), modules.end(), show_and_load); + scm_c_eval_string ("(activate-readline)"); + scm_c_eval_string ("(start-repl)"); + } + cleanup_and_exit_with_save (); +} + + +#ifdef HAVE_PYTHON_H +static void +python_cleanup_and_exit (PyConfig& config, PyStatus& status) +{ + if (qof_book_session_not_saved (gnc_get_current_book())) + std::cerr << _("Book is readonly. Unsaved changes will be lost.") << std::endl; + gnc_clear_current_session (); + + PyConfig_Clear(&config); + if (status.err_msg && *status.err_msg) + std::cerr << bl::format (_("Python Config failed with error {1}")) % status.err_msg + << std::endl; + gnc_shutdown_cli (status.exitcode); +} + +static void +run_python_cli (int argc, char **argv, scripting_args* args) +{ + PyConfig config; + PyConfig_InitPythonConfig(&config); + + PyStatus status = PyConfig_SetBytesArgv(&config, argc, argv); + if (PyStatus_Exception(status)) + python_cleanup_and_exit (config, status); + + status = Py_InitializeFromConfig(&config); + if (PyStatus_Exception(status)) + python_cleanup_and_exit (config, status); + + PyConfig_Clear(&config); + + if (args->script) + { + auto script_filename = args->script->c_str(); + PINFO ("Running python script %s...", script_filename); + auto fp = fopen (script_filename, "rb"); + if (!fp) + { + std::cerr << bl::format (_("Unable to load Python script {1}")) % script_filename + << std::endl; + python_cleanup_and_exit (config, status); + } + else if (PyRun_SimpleFileEx (fp, script_filename, 1) != 0) + { + std::cerr << bl::format (_("Python script {1} execution failed.")) % script_filename + << std::endl; + python_cleanup_and_exit (config, status); + } + } + if (args->interactive) + { + std::cout << _("Welcome to Gnucash Interactive Python Session") << std::endl; + PyRun_InteractiveLoop (stdin, "foo"); + } + Py_Finalize(); + cleanup_and_exit_with_save (); +} +#endif + +int +Gnucash::run_scripting (std::vector newArgv, + const bo_str& file_to_load, + std::string& language, + const bo_str& script, + bool open_readwrite, + bool interactive) +{ + std::vector errors; + static const std::vector languages = { "guile", "python" }; + + if (open_readwrite && !file_to_load) + errors.push_back (_ ("--readwrite: missing datafile!")); + + if (script && (!boost::filesystem::is_regular_file (*script))) + errors.push_back ((bl::format (_("--script: {1} is not a file")) % *script).str()); + + if (std::none_of (languages.begin(), languages.end(), + [&language](auto& lang){ return language == lang; })) + errors.push_back (_ ("--language: must be 'python' or 'guile'")); +#ifndef HAVE_PYTHON_H + else if (language == "python") + errors.push_back (_("--language: python wasn't compiled in this build")); +#endif + + if (!errors.empty()) + { + std::cerr << _("Errors parsing arguments:") << std::endl; + auto to_console = [](const auto& str){ std::cerr << str << std::endl; }; + std::for_each (errors.begin(), errors.end(), to_console); + gnc_shutdown_cli (1); + } + + gnc_prefs_init (); + gnc_ui_util_init(); + if (file_to_load && boost::filesystem::is_regular_file (*file_to_load)) + [[maybe_unused]] auto session = load_file (*file_to_load, open_readwrite); + + scripting_args args { script, interactive }; + if (language == "guile") + { + scm_boot_guile (newArgv.size(), (char**)newArgv.data(), run_guile_cli, &args); + return 0; // never reached... + } +#ifdef HAVE_PYTHON_H + else if (language == "python") + { + run_python_cli (newArgv.size(), (char**)newArgv.data(), &args); + return 0; // never reached... + } +#endif + + return 0; // never reached +} diff --git a/gnucash/gnucash-commands.hpp b/gnucash/gnucash-commands.hpp index 5a32e9056b..55671dcaa5 100644 --- a/gnucash/gnucash-commands.hpp +++ b/gnucash/gnucash-commands.hpp @@ -46,5 +46,11 @@ namespace Gnucash { int report_list (void); int report_show (const bo_str& file_to_load, const bo_str& run_report); + int run_scripting (std::vector newArgv, + const bo_str& m_file_to_load, + std::string& m_language, + const bo_str& m_script, + bool m_open_readwrite, + bool m_interactive); } #endif diff --git a/gnucash/gnucash-core-app.cpp b/gnucash/gnucash-core-app.cpp index c9a3715c7d..fa648618bf 100644 --- a/gnucash/gnucash-core-app.cpp +++ b/gnucash/gnucash-core-app.cpp @@ -294,7 +294,7 @@ Gnucash::CoreApp::add_common_program_options (void) ("input-file", bpo::value (&m_file_to_load), _("[datafile]")); - m_pos_opt_desc.add("input-file", -1); + m_pos_opt_desc.add("input-file", 1); m_opt_desc_all.add (common_options); m_opt_desc_all.add (hidden_options);