From 9372e0dfe092e45cc17b36140d0a3182d9747833 Mon Sep 17 00:00:00 2001 From: Campbell Barton Date: Fri, 8 Mar 2024 11:07:41 +1100 Subject: [PATCH] CLI: support defining custom commands via C++ & Python API Add support for add-ons to define commands using the new argument `-c` or `--command`. Commands behave as follows: - Passing in a command enables background mode without the need to pass in `--background`. - All arguments following the command are passed to the command (without the need to use the `--` argument). - Add-ons can define their own commands via `bpy.utils.register_cli_command` (see examples in API docs). - Passing in `--command help` lists all available commands. Ref !119115 --- .../bpy.utils.register_cli_command.1.py | 74 ++++ .../bpy.utils.register_cli_command.py | 43 +++ scripts/modules/bpy/utils/__init__.py | 4 + source/blender/blenkernel/BKE_blender.hh | 1 + .../blenkernel/BKE_blender_cli_command.hh | 67 ++++ source/blender/blenkernel/CMakeLists.txt | 2 + .../blenkernel/intern/blender_cli_command.cc | 181 ++++++++++ source/blender/python/intern/CMakeLists.txt | 2 + source/blender/python/intern/bpy.cc | 5 + .../blender/python/intern/bpy_cli_command.cc | 315 ++++++++++++++++++ .../blender/python/intern/bpy_cli_command.h | 20 ++ .../windowmanager/intern/wm_init_exit.cc | 9 + source/creator/creator.cc | 18 +- source/creator/creator_args.cc | 30 ++ source/creator/creator_intern.h | 6 + 15 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 doc/python_api/examples/bpy.utils.register_cli_command.1.py create mode 100644 doc/python_api/examples/bpy.utils.register_cli_command.py create mode 100644 source/blender/blenkernel/BKE_blender_cli_command.hh create mode 100644 source/blender/blenkernel/intern/blender_cli_command.cc create mode 100644 source/blender/python/intern/bpy_cli_command.cc create mode 100644 source/blender/python/intern/bpy_cli_command.h diff --git a/doc/python_api/examples/bpy.utils.register_cli_command.1.py b/doc/python_api/examples/bpy.utils.register_cli_command.1.py new file mode 100644 index 00000000000..238bc2fb9cf --- /dev/null +++ b/doc/python_api/examples/bpy.utils.register_cli_command.1.py @@ -0,0 +1,74 @@ +""" +Using Python Argument Parsing +----------------------------- + +This example shows how the Python ``argparse`` module can be used with a custom command. + +Using ``argparse`` is generally recommended as it has many useful utilities and +generates a ``--help`` message for your command. +""" + +import os +import sys + +import bpy + + +def argparse_create(): + import argparse + + parser = argparse.ArgumentParser( + prog=os.path.basename(sys.argv[0]) + " --command keyconfig_export", + description="Write key-configuration to a file.", + ) + + parser.add_argument( + "-o", "--output", + dest="output", + metavar='OUTPUT', + type=str, + help="The path to write the keymap to.", + required=True, + ) + + parser.add_argument( + "-a", "--all", + dest="all", + action="store_true", + help="Write all key-maps (not only customized key-maps).", + required=False, + ) + + return parser + + +def keyconfig_export(argv): + parser = argparse_create() + args = parser.parse_args(argv) + + # Ensure the key configuration is loaded in background mode. + bpy.utils.keyconfig_init() + + bpy.ops.preferences.keyconfig_export( + filepath=args.output, + all=args.all, + ) + + return 0 + + +cli_commands = [] + + +def register(): + cli_commands.append(bpy.utils.register_cli_command("keyconfig_export", keyconfig_export)) + + +def unregister(): + for cmd in cli_commands: + bpy.utils.unregister_cli_command(cmd) + cli_commands.clear() + + +if __name__ == "__main__": + register() diff --git a/doc/python_api/examples/bpy.utils.register_cli_command.py b/doc/python_api/examples/bpy.utils.register_cli_command.py new file mode 100644 index 00000000000..3f2a7a8808b --- /dev/null +++ b/doc/python_api/examples/bpy.utils.register_cli_command.py @@ -0,0 +1,43 @@ +""" +Custom Commands +--------------- + +Registering commands makes it possible to conveniently expose command line +functionality via commands passed to (``-c`` / ``--command``). +""" + +import sys +import os + + +def sysinfo_command(argv): + import tempfile + import sys_info + + if argv and argv[0] == "--help": + print("Print system information & exit!") + return 0 + + with tempfile.TemporaryDirectory() as tempdir: + filepath = os.path.join(tempdir, "system_info.txt") + sys_info.write_sysinfo(filepath) + with open(filepath, "r", encoding="utf-8") as fh: + sys.stdout.write(fh.read()) + return 0 + + +cli_commands = [] + + +def register(): + cli_commands.append(bpy.utils.register_cli_command("sysinfo", sysinfo_command)) + + +def unregister(): + for cmd in cli_commands: + bpy.utils.unregister_cli_command(cmd) + cli_commands.clear() + + +if __name__ == "__main__": + register() diff --git a/scripts/modules/bpy/utils/__init__.py b/scripts/modules/bpy/utils/__init__.py index a8ba8ad6de8..9261ce1bcf6 100644 --- a/scripts/modules/bpy/utils/__init__.py +++ b/scripts/modules/bpy/utils/__init__.py @@ -21,6 +21,8 @@ __all__ = ( "refresh_script_paths", "app_template_paths", "register_class", + "register_cli_command", + "unregister_cli_command", "register_manual_map", "unregister_manual_map", "register_classes_factory", @@ -49,9 +51,11 @@ from _bpy import ( flip_name, unescape_identifier, register_class, + register_cli_command, resource_path, script_paths as _bpy_script_paths, unregister_class, + unregister_cli_command, user_resource as _user_resource, system_resource, ) diff --git a/source/blender/blenkernel/BKE_blender.hh b/source/blender/blenkernel/BKE_blender.hh index 8e442ce6c4b..98dfbbb719e 100644 --- a/source/blender/blenkernel/BKE_blender.hh +++ b/source/blender/blenkernel/BKE_blender.hh @@ -12,6 +12,7 @@ struct Main; struct UserDef; +struct bContext; /** * Only to be called on exit Blender. diff --git a/source/blender/blenkernel/BKE_blender_cli_command.hh b/source/blender/blenkernel/BKE_blender_cli_command.hh new file mode 100644 index 00000000000..162f1af7f4f --- /dev/null +++ b/source/blender/blenkernel/BKE_blender_cli_command.hh @@ -0,0 +1,67 @@ +/* SPDX-FileCopyrightText: 2024 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +#pragma once + +/** \file + * \ingroup bke + * \brief Blender CLI Generic `--command` Support. + * + * \note all registered commands must print help to the STDOUT & exit with a zero exit-code + * when `--help` is passed in as the first argument to a command. + */ + +#include "BLI_utility_mixins.hh" + +#include +#include + +/** + * Each instance of this class can run the command with an argument list. + * The arguments begin at the first argument after the command identifier. + */ +class CommandHandler : blender::NonCopyable, blender::NonMovable { + public: + CommandHandler(const std::string &id) : id(id) {} + virtual ~CommandHandler() = default; + + /** Matched against `--command {id}`. */ + const std::string id; + + /** + * The main execution function. + * The return value is used as the commands exit-code. + */ + virtual int exec(struct bContext *C, int argc, const char **argv) = 0; + + /** True when one or more registered commands share an ID. */ + bool is_duplicate = false; +}; +/** + * \param cmd: The memory for a command type (ownership is transferred). + */ +void BKE_blender_cli_command_register(std::unique_ptr cmd); + +/** + * Unregister a previously registered command. + */ +bool BKE_blender_cli_command_unregister(CommandHandler *cmd); + +/** + * Run the command by `id`, passing in the argument list & context. + * The argument list must begin after the command identifier. + */ +int BKE_blender_cli_command_exec(struct bContext *C, + const char *id, + const int argc, + const char **argv); + +/** + * Print all known commands (used for passing `--command help` in the command-line). + */ +void BKE_blender_cli_command_print_help(); +/** + * Frees all commands (using their #CommandFreeFn call-backs). + */ +void BKE_blender_cli_command_free_all(); diff --git a/source/blender/blenkernel/CMakeLists.txt b/source/blender/blenkernel/CMakeLists.txt index b17961520c3..f21ef3a8d3a 100644 --- a/source/blender/blenkernel/CMakeLists.txt +++ b/source/blender/blenkernel/CMakeLists.txt @@ -76,6 +76,7 @@ set(SRC intern/bake_items_serialize.cc intern/bake_items_socket.cc intern/blender.cc + intern/blender_cli_command.cc intern/blender_copybuffer.cc intern/blender_undo.cc intern/blender_user_menu.cc @@ -345,6 +346,7 @@ set(SRC BKE_bake_items_serialize.hh BKE_bake_items_socket.hh BKE_blender.hh + BKE_blender_cli_command.hh BKE_blender_copybuffer.hh BKE_blender_undo.hh BKE_blender_user_menu.hh diff --git a/source/blender/blenkernel/intern/blender_cli_command.cc b/source/blender/blenkernel/intern/blender_cli_command.cc new file mode 100644 index 00000000000..782402a0c82 --- /dev/null +++ b/source/blender/blenkernel/intern/blender_cli_command.cc @@ -0,0 +1,181 @@ +/* SPDX-FileCopyrightText: 2001-2002 NaN Holding BV. All rights reserved. + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * \ingroup bke + * + * Generic CLI "--command" declarations. + * + * Duplicate Commands + * ================== + * + * When two or more commands share the same identifier, a warning is printed and both are disabled. + * + * This is done because command-line actions may be destructive so the down-side of running the + * wrong command could be severe. The reason this is not considered an error is we can't prevent + * it so easily, unlike operator ID's which may be longer, commands are typically short terms + * which wont necessarily include an add-ons identifier as a prefix for e.g. + * Further, an error would break loading add-ons who's primary is *not* + * necessarily to provide command-line access. + * An alternative solution could be to generate unique names (number them for example) + * but this isn't reliable as it would depend on it the order add-ons are loaded which + * isn't under user control. + */ + +#include + +#include "BLI_vector.hh" + +#include "BKE_blender_cli_command.hh" /* own include */ + +#include "MEM_guardedalloc.h" + +/* -------------------------------------------------------------------- */ +/** \name Internal API + * \{ */ + +using CommandHandlerPtr = std::unique_ptr; + +/** + * All registered command handlers. + * \note the order doesn't matter as duplicates are detected and prevented from running. + */ +blender::Vector g_command_handlers; + +static CommandHandler *blender_cli_command_lookup(const std::string &id) +{ + for (CommandHandlerPtr &cmd_iter : g_command_handlers) { + if (id == cmd_iter->id) { + return cmd_iter.get(); + } + } + return nullptr; +} + +static int blender_cli_command_index(const CommandHandler *cmd) +{ + int index = 0; + for (CommandHandlerPtr &cmd_iter : g_command_handlers) { + if (cmd_iter.get() == cmd) { + return index; + } + index++; + } + return -1; +} + +/** \} */ + +/* -------------------------------------------------------------------- */ +/** \name Public API + * \{ */ + +void BKE_blender_cli_command_register(std::unique_ptr cmd) +{ + bool is_duplicate = false; + if (CommandHandler *cmd_exists = blender_cli_command_lookup(cmd->id)) { + std::cerr << "warning: registered duplicate command \"" << cmd->id + << "\", this will be inaccessible" << std::endl; + cmd_exists->is_duplicate = true; + is_duplicate = true; + } + cmd->is_duplicate = is_duplicate; + g_command_handlers.append(std::move(cmd)); +} + +bool BKE_blender_cli_command_unregister(CommandHandler *cmd) +{ + const int cmd_index = blender_cli_command_index(cmd); + if (cmd_index == -1) { + std::cerr << "failed to unregister command handler" << std::endl; + return false; + } + + /* Update duplicates after removal. */ + if (cmd->is_duplicate) { + CommandHandler *cmd_other = nullptr; + for (CommandHandlerPtr &cmd_iter : g_command_handlers) { + /* Skip self. */ + if (cmd == cmd_iter.get()) { + continue; + } + if (cmd_iter->is_duplicate && (cmd_iter->id == cmd->id)) { + if (cmd_other) { + /* Two or more found, clear and break. */ + cmd_other = nullptr; + break; + } + cmd_other = cmd_iter.get(); + } + } + if (cmd_other) { + cmd_other->is_duplicate = false; + } + } + + g_command_handlers.remove_and_reorder(cmd_index); + + return true; +} + +int BKE_blender_cli_command_exec(bContext *C, const char *id, const int argc, const char **argv) +{ + CommandHandler *cmd = blender_cli_command_lookup(id); + if (cmd == nullptr) { + std::cerr << "Unrecognized command: \"" << id << "\"" << std::endl; + return EXIT_FAILURE; + } + if (cmd->is_duplicate) { + std::cerr << "Command: \"" << id + << "\" was registered multiple times, must be resolved, aborting!" << std::endl; + return EXIT_FAILURE; + } + + return cmd->exec(C, argc, argv); +} + +void BKE_blender_cli_command_print_help() +{ + /* As this is isn't ordered sorting in-place is acceptable, + * sort alphabetically for display purposes only. */ + std::sort(g_command_handlers.begin(), + g_command_handlers.end(), + [](const CommandHandlerPtr &a, const CommandHandlerPtr &b) { return a->id < b->id; }); + + for (int pass = 0; pass < 2; pass++) { + std::cout << ((pass == 0) ? "Blender Command Listing:" : + "Duplicate Command Listing (ignored):") + << std::endl; + + const bool is_duplicate = pass > 0; + bool found = false; + bool has_duplicate = false; + for (CommandHandlerPtr &cmd_iter : g_command_handlers) { + if (cmd_iter->is_duplicate) { + has_duplicate = true; + } + if (cmd_iter->is_duplicate != is_duplicate) { + continue; + } + + std::cout << "\t" << cmd_iter->id << std::endl; + found = true; + } + + if (!found) { + std::cout << "\tNone found" << std::endl; + } + /* Don't print that no duplicates are found as it's not helpful. */ + if (pass == 0 && !has_duplicate) { + break; + } + } +} + +void BKE_blender_cli_command_free_all() +{ + g_command_handlers.clear(); +} + +/** \} */ diff --git a/source/blender/python/intern/CMakeLists.txt b/source/blender/python/intern/CMakeLists.txt index fd9770b96b9..69795ea86f7 100644 --- a/source/blender/python/intern/CMakeLists.txt +++ b/source/blender/python/intern/CMakeLists.txt @@ -40,6 +40,7 @@ set(SRC bpy_app_translations.cc bpy_app_usd.cc bpy_capi_utils.cc + bpy_cli_command.cc bpy_driver.cc bpy_gizmo_wrap.cc bpy_interface.cc @@ -87,6 +88,7 @@ set(SRC bpy_app_translations.h bpy_app_usd.h bpy_capi_utils.h + bpy_cli_command.h bpy_driver.h bpy_gizmo_wrap.h bpy_intern_string.h diff --git a/source/blender/python/intern/bpy.cc b/source/blender/python/intern/bpy.cc index aae42841918..6731827fdfa 100644 --- a/source/blender/python/intern/bpy.cc +++ b/source/blender/python/intern/bpy.cc @@ -34,6 +34,7 @@ #include "bpy.h" #include "bpy_app.h" +#include "bpy_cli_command.h" #include "bpy_driver.h" #include "bpy_library.h" #include "bpy_operator.h" @@ -734,6 +735,10 @@ void BPy_init_modules(bContext *C) PYMODULE_ADD_METHOD(mod, &meth_bpy_owner_id_get); PYMODULE_ADD_METHOD(mod, &meth_bpy_owner_id_set); + /* Register command functions. */ + PYMODULE_ADD_METHOD(mod, &BPY_cli_command_register_def); + PYMODULE_ADD_METHOD(mod, &BPY_cli_command_unregister_def); + #undef PYMODULE_ADD_METHOD /* add our own modules dir, this is a python package */ diff --git a/source/blender/python/intern/bpy_cli_command.cc b/source/blender/python/intern/bpy_cli_command.cc new file mode 100644 index 00000000000..6e84f9cb2d9 --- /dev/null +++ b/source/blender/python/intern/bpy_cli_command.cc @@ -0,0 +1,315 @@ +/* SPDX-FileCopyrightText: 2024 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * \ingroup pythonintern + * + * Wrap `BKE_command_cli_*` to support custom CLI commands. + */ +#include + +#include "BLI_utildefines.h" + +#include "bpy_capi_utils.h" + +#include "MEM_guardedalloc.h" + +#include "BKE_blender_cli_command.hh" + +#include "../generic/py_capi_utils.h" +#include "../generic/python_compat.h" +#include "../generic/python_utildefines.h" + +#include "bpy_cli_command.h" /* Own include. */ + +static const char *bpy_cli_command_capsule_name = "bpy_cli_command"; +static const char *bpy_cli_command_capsule_name_invalid = "bpy_cli_command"; + +/* -------------------------------------------------------------------- */ +/** \name Internal Utilities + * \{ */ + +/** + * Return a list of strings, compatible with the construction of Python's `sys.argv`. + */ +static PyObject *py_argv_from_bytes(const int argc, const char **argv) +{ + /* Copy functionality from Python's internal `sys.argv` initialization. */ + PyConfig config; + PyConfig_InitPythonConfig(&config); + PyStatus status = PyConfig_SetBytesArgv(&config, argc, (char *const *)argv); + PyObject *py_argv = nullptr; + if (UNLIKELY(PyStatus_Exception(status))) { + PyErr_Format(PyExc_ValueError, "%s", status.err_msg); + } + else { + BLI_assert(argc == config.argv.length); + py_argv = PyList_New(config.argv.length); + for (Py_ssize_t i = 0; i < config.argv.length; i++) { + PyList_SET_ITEM(py_argv, i, PyUnicode_FromWideChar(config.argv.items[i], -1)); + } + } + PyConfig_Clear(&config); + return py_argv; +} + +/** \} */ + +/* -------------------------------------------------------------------- */ +/** \name Internal Implementation + * \{ */ + +static int bpy_cli_command_exec(struct bContext *C, + PyObject *py_exec_fn, + const int argc, + const char **argv) +{ + int exit_code = EXIT_FAILURE; + PyGILState_STATE gilstate; + bpy_context_set(C, &gilstate); + + /* For the most part `sys.argv[-argc:]` is sufficient & less trouble than re-creating this + * list. Don't do this because: + * - Python scripts *could* have manipulated `sys.argv` (although it's bad practice). + * - We may want to support invoking commands directly, + * where the arguments aren't necessarily from `sys.argv`. + */ + bool has_error = false; + PyObject *py_argv = py_argv_from_bytes(argc, argv); + + if (py_argv == nullptr) { + has_error = true; + } + else { + PyObject *exec_args = PyTuple_New(1); + PyTuple_SET_ITEM(exec_args, 0, py_argv); + + PyObject *result = PyObject_Call(py_exec_fn, exec_args, nullptr); + + Py_DECREF(exec_args); /* Frees `py_argv` too. */ + + /* Convert `sys.exit` into a return-value. + * NOTE: typically `sys.exit` *doesn't* need any special handling, + * however it's neater if we use the same code paths for exiting either way. */ + if ((result == nullptr) && PyErr_ExceptionMatches(PyExc_SystemExit)) { + PyObject *error_type, *error_value, *error_traceback; + PyErr_Fetch(&error_type, &error_value, &error_traceback); + if (PyObject_TypeCheck(error_value, (PyTypeObject *)PyExc_SystemExit) && + (((PySystemExitObject *)error_value)->code != nullptr)) + { + /* When `SystemExit(..)` is raised. */ + result = ((PySystemExitObject *)error_value)->code; + } + else { + /* When `sys.exit()` is called. */ + result = error_value; + } + Py_INCREF(result); + PyErr_Restore(error_type, error_value, error_traceback); + PyErr_Clear(); + } + + if (result == nullptr) { + has_error = true; + } + else { + if (!PyLong_Check(result)) { + PyErr_Format(PyExc_TypeError, + "Expected an int return value, not a %.200s", + Py_TYPE(result)->tp_name); + has_error = true; + } + else { + const int exit_code_test = PyC_Long_AsI32(result); + if ((exit_code_test == -1) && PyErr_Occurred()) { + exit_code = EXIT_SUCCESS; + has_error = true; + } + else { + exit_code = exit_code_test; + } + } + Py_DECREF(result); + } + } + + if (has_error) { + PyErr_Print(); + PyErr_Clear(); + } + + bpy_context_clear(C, &gilstate); + + return exit_code; +} + +static void bpy_cli_command_free(PyObject *py_exec_fn) +{ + /* An explicit unregister clears to avoid acquiring a lock. */ + if (py_exec_fn) { + PyGILState_STATE gilstate = PyGILState_Ensure(); + Py_DECREF(py_exec_fn); + PyGILState_Release(gilstate); + } +} + +/** \} */ + +/* -------------------------------------------------------------------- */ +/** \name Internal Class + * \{ */ + +class BPyCommandHandler : public CommandHandler { + public: + BPyCommandHandler(const std::string &id, PyObject *py_exec_fn) + : CommandHandler(id), py_exec_fn(py_exec_fn) + { + } + ~BPyCommandHandler() override + { + bpy_cli_command_free(this->py_exec_fn); + } + + int exec(struct bContext *C, int argc, const char **argv) override + { + return bpy_cli_command_exec(C, this->py_exec_fn, argc, argv); + } + + PyObject *py_exec_fn = nullptr; +}; + +/** \} */ + +/* -------------------------------------------------------------------- */ +/** \name Public Methods + * \{ */ + +PyDoc_STRVAR( + /* Wrap. */ + bpy_cli_command_register_doc, + ".. method:: register_cli_command(id, execute)\n" + "\n" + " Register a command, accessible via the (``-c`` / ``--command``) command-line argument.\n" + "\n" + " :arg id: The command identifier (must pass an ``str.isidentifier`` check).\n" + "\n" + " If the ``id`` is already registered, a warning is printed and " + "the command is inaccessible to prevent accidents invoking the wrong command." + " :type id: str\n" + " :arg execute: Callback, taking a single list of strings and returns an int.\n" + " The arguments are built from all command-line arguments following the command id.\n" + " The return value should be 0 for success, 1 on failure " + "(specific error codes from the ``os`` module can also be used).\n" + " :type execute: callable\n" + " :return: The command handle which can be passed to :func:`unregister_cli_command`.\n" + " :rtype: capsule\n"); +static PyObject *bpy_cli_command_register(PyObject * /*self*/, PyObject *args, PyObject *kw) +{ + PyObject *py_id; + PyObject *py_exec_fn; + + static const char *_keywords[] = { + "id", + "execute", + nullptr, + }; + static _PyArg_Parser _parser = { + PY_ARG_PARSER_HEAD_COMPAT() + "O!" /* `id` */ + "O" /* `execute` */ + ":register_cli_command", + _keywords, + nullptr, + }; + if (!_PyArg_ParseTupleAndKeywordsFast(args, kw, &_parser, &PyUnicode_Type, &py_id, &py_exec_fn)) + { + return nullptr; + } + if (!PyUnicode_IsIdentifier(py_id)) { + PyErr_SetString(PyExc_ValueError, "The command id is not a valid identifier"); + return nullptr; + } + if (!PyCallable_Check(py_exec_fn)) { + PyErr_SetString(PyExc_ValueError, "The execute argument must be callable"); + return nullptr; + } + + const char *id = PyUnicode_AsUTF8(py_id); + + std::unique_ptr cmd_ptr = std::make_unique( + std::string(id), Py_INCREF_RET(py_exec_fn)); + void *cmd_p = cmd_ptr.get(); + + BKE_blender_cli_command_register(std::move(cmd_ptr)); + + return PyCapsule_New(cmd_p, bpy_cli_command_capsule_name, nullptr); +} + +PyDoc_STRVAR( + /* Wrap. */ + bpy_cli_command_unregister_doc, + ".. method:: unregister_cli_command(handle)\n" + "\n" + " Unregister a CLI command.\n" + "\n" + " :arg handle: The return value of :func:`register_cli_command`.\n" + " :type handle: capsule\n"); +static PyObject *bpy_cli_command_unregister(PyObject * /*self*/, PyObject *value) +{ + if (!PyCapsule_CheckExact(value)) { + PyErr_Format(PyExc_TypeError, + "Expected a capsule returned from register_cli_command(...), found a: %.200s", + Py_TYPE(value)->tp_name); + return nullptr; + } + + BPyCommandHandler *cmd = static_cast( + PyCapsule_GetPointer(value, bpy_cli_command_capsule_name)); + if (cmd == nullptr) { + const char *capsule_name = PyCapsule_GetName(value); + if (capsule_name == bpy_cli_command_capsule_name_invalid) { + PyErr_SetString(PyExc_ValueError, "The command has already been removed"); + } + else { + PyErr_Format(PyExc_ValueError, + "Unrecognized capsule ID \"%.200s\"", + capsule_name ? capsule_name : ""); + } + return nullptr; + } + + /* Don't acquire the GIL when un-registering. */ + Py_CLEAR(cmd->py_exec_fn); + + /* Don't allow removing again. */ + PyCapsule_SetName(value, bpy_cli_command_capsule_name_invalid); + + BKE_blender_cli_command_unregister((CommandHandler *)cmd); + + Py_RETURN_NONE; +} + +#if (defined(__GNUC__) && !defined(__clang__)) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wcast-function-type" +#endif + +PyMethodDef BPY_cli_command_register_def = { + "register_cli_command", + (PyCFunction)bpy_cli_command_register, + METH_STATIC | METH_VARARGS | METH_KEYWORDS, + bpy_cli_command_register_doc, +}; +PyMethodDef BPY_cli_command_unregister_def = { + "unregister_cli_command", + (PyCFunction)bpy_cli_command_unregister, + METH_STATIC | METH_O, + bpy_cli_command_unregister_doc, +}; + +#if (defined(__GNUC__) && !defined(__clang__)) +# pragma GCC diagnostic pop +#endif + +/** \} */ diff --git a/source/blender/python/intern/bpy_cli_command.h b/source/blender/python/intern/bpy_cli_command.h new file mode 100644 index 00000000000..b7dac5ed068 --- /dev/null +++ b/source/blender/python/intern/bpy_cli_command.h @@ -0,0 +1,20 @@ +/* SPDX-FileCopyrightText: 2023 Blender Authors + * + * SPDX-License-Identifier: GPL-2.0-or-later */ + +/** \file + * \ingroup pythonintern + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern PyMethodDef BPY_cli_command_register_def; +extern PyMethodDef BPY_cli_command_unregister_def; + +#ifdef __cplusplus +} +#endif diff --git a/source/blender/windowmanager/intern/wm_init_exit.cc b/source/blender/windowmanager/intern/wm_init_exit.cc index c2f005c2701..191baa90639 100644 --- a/source/blender/windowmanager/intern/wm_init_exit.cc +++ b/source/blender/windowmanager/intern/wm_init_exit.cc @@ -51,6 +51,7 @@ #include "BKE_addon.h" #include "BKE_appdir.hh" +#include "BKE_blender_cli_command.hh" #include "BKE_mask.h" /* free mask clipboard */ #include "BKE_material.h" /* BKE_material_copybuf_clear */ #include "BKE_studiolight.h" @@ -535,6 +536,14 @@ void WM_exit_ex(bContext *C, const bool do_python_exit, const bool do_user_exit_ } #endif + /* Perform this early in case commands reference other data freed later in this function. + * This most run: + * - After add-ons are disabled because they may unregister commands. + * - Before Python exits so Python objects can be de-referenced. + * - Before #BKE_blender_atexit runs they free the `argv` on WIN32. + */ + BKE_blender_cli_command_free_all(); + BLI_timer_free(); WM_paneltype_clear(); diff --git a/source/creator/creator.cc b/source/creator/creator.cc index 4e709266fbf..ee0739dcd76 100644 --- a/source/creator/creator.cc +++ b/source/creator/creator.cc @@ -37,6 +37,7 @@ /* Mostly initialization functions. */ #include "BKE_appdir.hh" #include "BKE_blender.hh" +#include "BKE_blender_cli_command.hh" #include "BKE_brush.hh" #include "BKE_cachefile.hh" #include "BKE_callbacks.hh" @@ -566,8 +567,23 @@ int main(int argc, #ifndef WITH_PYTHON_MODULE if (G.background) { + int exit_code; + if (app_state.command.argv) { + const char *id = app_state.command.argv[0]; + if (STREQ(id, "help")) { + BKE_blender_cli_command_print_help(); + exit_code = EXIT_SUCCESS; + } + else { + exit_code = BKE_blender_cli_command_exec( + C, id, app_state.command.argc - 1, app_state.command.argv + 1); + } + } + else { + exit_code = G.is_break ? EXIT_FAILURE : EXIT_SUCCESS; + } /* Using window-manager API in background-mode is a bit odd, but works fine. */ - WM_exit(C, G.is_break ? EXIT_FAILURE : EXIT_SUCCESS); + WM_exit(C, exit_code); } else { /* Shows the splash as needed. */ diff --git a/source/creator/creator_args.cc b/source/creator/creator_args.cc index e745c029808..902ee8f46dd 100644 --- a/source/creator/creator_args.cc +++ b/source/creator/creator_args.cc @@ -698,6 +698,8 @@ static void print_help(bArgs *ba, bool all) PRINT("\n"); BLI_args_print_arg_doc(ba, "-noaudio"); BLI_args_print_arg_doc(ba, "-setaudio"); + PRINT("\n"); + BLI_args_print_arg_doc(ba, "--command"); PRINT("\n"); @@ -924,6 +926,32 @@ static int arg_handle_background_mode_set(int /*argc*/, const char ** /*argv*/, return 0; } +static const char arg_handle_command_set_doc[] = + "\n" + "\tRun a command which consumes all remaining arguments.\n" + "\tUse '-c help' to list all other commands.\n" + "\tPass '--help' after the command to see its help text.\n" + "\n" + "\tThis implies '--background' mode."; +static int arg_handle_command_set(int argc, const char **argv, void * /*data*/) +{ + if (argc < 2) { + fprintf(stderr, "%s requires at least one argument\n", argv[0]); + exit(EXIT_FAILURE); + BLI_assert_unreachable(); + } + + /* See `--background` implementation. */ + G.background = true; + BKE_sound_force_device("None"); + + app_state.command.argc = argc - 1; + app_state.command.argv = argv + 1; + + /* Consume remaining arguments. */ + return argc - 1; +} + static const char arg_handle_log_level_set_doc[] = "\n" "\tSet the logging verbosity level (higher for more details) defaults to 1,\n" @@ -2374,6 +2402,8 @@ void main_args_setup(bContext *C, bArgs *ba, bool all) ba, nullptr, "--disable-abort-handler", CB(arg_handle_abort_handler_disable), nullptr); BLI_args_add(ba, "-b", "--background", CB(arg_handle_background_mode_set), nullptr); + /* Command implies background mode. */ + BLI_args_add(ba, "-c", "--command", CB(arg_handle_command_set), nullptr); BLI_args_add(ba, "-a", nullptr, CB(arg_handle_playback_mode), nullptr); diff --git a/source/creator/creator_intern.h b/source/creator/creator_intern.h index e00e5756e6c..29706d610d4 100644 --- a/source/creator/creator_intern.h +++ b/source/creator/creator_intern.h @@ -52,6 +52,12 @@ struct ApplicationState { struct { unsigned char python; } exit_code_on_error; + + /** Storage for commands (see `--command` argument). */ + struct { + int argc; + const char **argv; + } command; }; extern struct ApplicationState app_state; /* creator.c */