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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
315
source/blender/python/intern/bpy_cli_command.cc
Normal file
315
source/blender/python/intern/bpy_cli_command.cc
Normal file
@@ -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 <Python.h>
|
||||
|
||||
#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<invalid>";
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/** \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<CommandHandler> cmd_ptr = std::make_unique<BPyCommandHandler>(
|
||||
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<BPyCommandHandler *>(
|
||||
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 : "<null>");
|
||||
}
|
||||
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
|
||||
|
||||
/** \} */
|
||||
20
source/blender/python/intern/bpy_cli_command.h
Normal file
20
source/blender/python/intern/bpy_cli_command.h
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user