//  ************************************************************************************************
//
//  BornAgain: simulate and fit reflection and scattering
//
//! @file      PyCore/Embed/PyInterpreter.cpp
//! @brief     Implements functions to expose Python-interpreter functionality to C++.
//!
//! @homepage  http://www.bornagainproject.org
//! @license   GNU General Public License v3 or higher (see COPYING)
//! @copyright Forschungszentrum Jülich GmbH 2018
//! @authors   Scientific Computing Group at MLZ (see CITATION, AUTHORS)
//
//  ************************************************************************************************

/* Embedded Python Interpreter

Note that Python objects are structures allocated on the heap,
accessed through pointers of type `PyObject*`.

References:
- Python C-API <https://docs.python.org/3/c-api>
- Python ABI stability <https://docs.python.org/3/c-api/stable.html>
- Numpy C-API <https://numpy.org/doc/stable/reference/c-api>;
  <https://numpy.org/doc/stable/reference/c-api/array.html>.
- Python Extension Patterns <https://pythonextensionpatterns.readthedocs.io>
- "Python behind the scenes" series <https://tenthousandmeters.com/tag/python-behind-the-scenes>
- Python's garbage collector <https://rushter.com/blog/python-garbage-collector>
*/

#include "PyCore/Embed/PyInterpreter.h"
#include "Base/Util/Assert.h"
#include "PyCore/Embed/PyObjectPtr.h"
#include "PyCore/Embed/WithNumpy.h"
#include <algorithm> // transform
#include <cstddef>   // NULL
#include <cstring>   // memcpy
#include <iostream>  // cerr
#include <memory>    // unique_ptr
#include <stdexcept> // runtime_error

namespace {

// thin wrapper to initialize Numpy
std::nullptr_t _init_numpy(int& status)
{
    if (!PyArray_API) {
        /* To use Numpy Array C-API from an extension module,
           `import_array` function must be called.
           If the extension module is self-contained in a single .c file,
           then that is all that needs to be done.
           This function imports the module where the function-pointer table
           is stored and points the correct variable to it.
        */
        // NOTE: Numpy's `import_array` returns `NULL` on failure;
        // hence return type must be `std::nullptr_t`.
        status = 1;
        import_array();
        status = 0; // Numpy Array API loaded successfully
        return NULL;
    }

    status = 2; // Numpy Array API is already loaded
    return NULL;
}

std::string toString(const wchar_t* const s)
{
    if (!s)
        return "";
    std::wstring w(s);
    std::string result(w.length(), 0);
    std::transform(w.begin(), w.end(), result.begin(), [](wchar_t c) { return (char)c; });
    return result;
}

//! Converts PyObject into vector of strings, if possible, or throws exception
std::vector<std::string> toVectorString(PyObject* py_object)
{
    std::vector<std::string> result;

    if (PyTuple_Check(py_object)) {
        for (Py_ssize_t i = 0; i < PyTuple_Size(py_object); i++) {
            PyObject* value = PyTuple_GetItem(py_object, i);
            result.push_back(PyInterpreter::pyStrtoString(value));
        }
    } else if (PyList_Check(py_object)) {
        for (Py_ssize_t i = 0; i < PyList_Size(py_object); i++) {
            PyObject* value = PyList_GetItem(py_object, i);
            result.push_back(PyInterpreter::pyStrtoString(value));
        }
    } else {
        throw std::runtime_error(PyInterpreter::errorDescription(
            "PyInterpreter: Cannnot convert the given Python object "
            "to vector<string>."));
    }

    return result;
}


// auxiliary function to convert a Numpy array data of real type `real_t`
// to vector<double>.
template <typename real_t> inline void _realArray2DToDouble(PyArrayObject* const npArray_ptr,
                                                            npy_intp rowIdxMax, npy_intp colIdxMax,
                                                            std::vector<double>& vector_out)
{
    std::size_t idx = 0;
    for (npy_intp i_row = 0; i_row < rowIdxMax; ++i_row) {
        for (npy_intp j_col = 0; j_col < colIdxMax; ++j_col) {
            const double val = static_cast<double>(
                *reinterpret_cast<real_t*>(PyArray_GETPTR2(npArray_ptr, i_row, j_col)));
            vector_out[idx] = val;
            ++idx;
        }
    }
}

} // namespace


// NOTE: "Python stable ABI" denotes the functions which use _only_ the Python's stable ABI;
// see <https://docs.python.org/3/c-api/stable.html>

// Python stable ABI
void PyInterpreter::initialize()
{
    if (!Py_IsInitialized())
        Py_Initialize();
}

// Python stable ABI
bool PyInterpreter::isInitialized()
{
    return static_cast<bool>(Py_IsInitialized());
}

// Python stable ABI
void PyInterpreter::finalize()
{
    // undo all initializations made by Py_Initialize() and subsequent use
    // of Python/C API functions, and destroy all sub-interpreters.
    // This is a no-op when called for a second time.
    Py_Finalize();
}

// Python stable ABI
bool PyInterpreter::checkError()
{
    if (PyErr_Occurred()) {
        // print a standard traceback to sys.stderr and clear the error indicator
        std::cerr << "---PyInterpreter: Error in Python interpreter:\n";
        PyErr_Print();
        std::cerr << "\n---\n";
        return true;
    }
    return false;
}

// Python stable ABI
void PyInterpreter::addPythonPath(const std::string& path)
{
    ASSERT(!path.empty());
    PyObject* sysPath = PySys_GetObject("path");
    PyList_Append(sysPath, PyUnicode_FromString(path.c_str())); // add to `PYTHONPATH`
}

// Python stable ABI
void PyInterpreter::setPythonPath(const std::string& path)
{
    // returns 0 on success, -1 on error
    const int result = PySys_SetObject((char*)"path", PyUnicode_FromString(path.c_str()));
    if (result != 0) {
        PyInterpreter::checkError();
        throw std::runtime_error("PyInterpreter.setPythonPath: Cannot set the Python path.");
    }
}

// Python stable ABI
PyObjectPtr PyInterpreter::import(const std::string& pymodule_name, const std::string& path)
{
    ASSERT(!pymodule_name.empty());

    // initialize numpy which might be needed
    PyInterpreter::Numpy::initialize();

    if (!path.empty())
        addPythonPath(path);

    // import the module
    PyObject* pymodule = PyImport_ImportModule(pymodule_name.c_str());
    if (!pymodule || !PyModule_Check(pymodule)) {
        checkError();
        throw std::runtime_error(errorDescription("PyInterpreter: Cannot load Python module '"
                                                  + pymodule_name + "' (given path = '" + path
                                                  + "')"));
    }

    // returns a _new_ reference; ie. caller is responsible for the ref-count
    return {pymodule};
}

// Python stable ABI
void PyInterpreter::DecRef(PyObject* py_object)
{
    Py_XDECREF(py_object);
}

std::string PyInterpreter::pyStrtoString(PyObject* py_object)
{
    std::string result;
    PyObject* pyStr = PyUnicode_AsEncodedString(py_object, "utf-8", "replace");
    if (pyStr) {
        result = std::string(PyBytes_AsString(pyStr));
        Py_DecRef(pyStr);
    }

    return result;
}

std::string PyInterpreter::getStrAttribute(PyObject* py_module, const std::string& attr_name)
{
    std::string result_str;
    PyObject* pAttr = PyObject_GetAttrString(py_module, attr_name.c_str());

    if (!pAttr) {
        Py_DecRef(pAttr);
        checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter: Cannot get Python attribute '" + attr_name + "'"));
    }

    result_str = PyInterpreter::pyStrtoString(pAttr);
    Py_DecRef(pAttr);
    return result_str;
}

void PyInterpreter::callFunction(PyObject* py_module, const std::string& fn_name)
{

    // NOTE: The Python function signature is assumed to be `fn() -> str`
    std::string result_str;
    PyObject *pFunc, *pRetValue;

    pFunc = PyObject_GetAttrString(py_module, fn_name.c_str());

    if (!pFunc || !PyCallable_Check(pFunc)) {
        Py_DecRef(pFunc);
        checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter: Cannot call Python function '" + fn_name + "'"));
    }

    // Call the function with no arguments
    pRetValue = PyObject_CallObject(pFunc, NULL);
    result_str = PyInterpreter::pyStrtoString(pRetValue);
    Py_DecRef(pRetValue);
    Py_DecRef(pFunc);
}


std::string PyInterpreter::runtimeInfo()
{
    std::string result;

    // Runtime environment
    result = std::string(60, '=') + "\n";
    // Embedded Python details
    result = +"Py_GetProgramName(): " + toString(Py_GetProgramName()) + "\n";
    result = +"Py_GetProgramFullPath(): " + toString(Py_GetProgramFullPath()) + "\n";
    result = +"Py_GetPath(): " + toString(Py_GetPath()) + "\n";
    result = +"Py_GetPythonHome(): " + toString(Py_GetPythonHome()) + "\n";

    // Runtime Python's sys.path
    PyObject* sysPath = PySys_GetObject((char*)"path");
    std::vector<std::string> content{toVectorString(sysPath)};
    result += "sys.path: ";
    for (const std::string& s : content)
        result += s + ",";
    result += "\n";

    return result;
}

// Attempt to retrieve Python stack trace
// Ref: <https://stackoverflow.com/q/1796510>
std::string PyInterpreter::stackTrace()
{
    std::string result;

    if (PyErr_Occurred()) {
        PyObject *ptype, *pvalue, *ptraceback, *pystr;

        PyErr_Fetch(&ptype, &pvalue, &ptraceback);
        pystr = PyObject_Str(pvalue);
        if (char* str = PyBytes_AsString(pystr))
            result += std::string(str) + "\n";
        Py_DecRef(pystr);

        PyObject* module_name = PyUnicode_FromString("traceback");
        PyObject* py_traceback_module = PyImport_Import(module_name);
        Py_DecRef(module_name);

        if (py_traceback_module) {
            result += "\n";
            PyObject* pyth_func = PyObject_GetAttrString(py_traceback_module, "format_exception");
            if (pyth_func && PyCallable_Check(pyth_func)) {
                PyObject* pyth_val =
                    PyObject_CallFunctionObjArgs(pyth_func, ptype, pvalue, ptraceback, NULL);
                Py_DecRef(pyth_func);
                if (pyth_val) {
                    pystr = PyObject_Str(pyth_val);
                    if (char* str = PyBytes_AsString(pystr))
                        result += std::string(str);
                    Py_DecRef(pystr);
                    Py_DecRef(pyth_val);
                }
            }
            result += "\n";
        }

        Py_DecRef(py_traceback_module);
    }

    result += "\n";
    result += PyInterpreter::runtimeInfo();
    result += "\n";

    return result;
}

std::string PyInterpreter::errorDescription(const std::string& title)
{
    std::string msg = title + "\n" + PyInterpreter::stackTrace() + "\n";
    return msg;
}


int PyInterpreter::Numpy::initialize()
{
    // initialize Python C API, if needed
    PyInterpreter::initialize();

    int res;
    _init_numpy(res);

    switch (res) {
    case 0:
        return res;
    case 1:
        throw std::runtime_error(errorDescription("PyInterpreter: Cannot initialize Numpy"));
    case 2:
        return res;
    }

    return res;
}

bool PyInterpreter::Numpy::isInitialized()
{
    return static_cast<bool>(PyArray_API);
}

std::vector<double> PyInterpreter::Numpy::createVectorFromArray2D(PyObject* pyobject_ptr)
{
    if (!pyobject_ptr || !PyArray_Check(pyobject_ptr)) {
        throw(std::runtime_error(errorDescription("PyInterpreter::Numpy: Cannot convert an invalid "
                                                  "Numpy array to a C-Array")));
    }

    PyArrayObject* npArray_ptr{reinterpret_cast<PyArrayObject*>(pyobject_ptr)};
    PyArray_Descr* npArray_descr = PyArray_DTYPE(npArray_ptr);
    const int npArray_type = PyArray_TYPE(npArray_ptr);
    const int npArray_ndim = PyArray_NDIM(npArray_ptr);
    npy_intp* npArray_dims = PyArray_DIMS(npArray_ptr);
    const std::size_t npArray_size = static_cast<std::size_t>(PyArray_SIZE(npArray_ptr));
    // const int npArray_flags = PyArray_FLAGS(npArray_ptr);
    // character code indicating the data type
    const char npArray_dtype = npArray_descr->type;

    // Numpy array type must be 2d
    if (npArray_ndim != 2) {
        std::string msg = "PyInterpreter::Numpy: Expected a Numpy 2d-array "
                          "(given number of dimensions is "
                          + std::to_string(npArray_ndim) + ")";
        throw std::runtime_error(errorDescription("PyInterpreter::Numpy: Expected a Numpy 2d-array "
                                                  "(given number of dimensions is "
                                                  + std::to_string(npArray_ndim) + ")"));
    }

    // Numpy array type must be numeric and real (eligible for casting to double)
    if (!PyDataType_ISNUMBER(npArray_descr) || PyDataType_ISCOMPLEX(npArray_descr)) {
        std::string msg = "PyInterpreter::Numpy: "
                          "Expected a Numpy array of numeric type and real "
                          "(given type '"
                          + std::to_string(npArray_dtype) + "')";
        throw std::runtime_error(errorDescription(msg));
    }

    std::vector<double> data(npArray_size);

    const npy_intp rowIdxMax = npArray_dims[0], colIdxMax = npArray_dims[1];

    // branch on type
    switch (npArray_type) {
    // float
    case NPY_DOUBLE:
        _realArray2DToDouble<npy_double>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    case NPY_FLOAT:
        _realArray2DToDouble<npy_float>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    // signed int
    case NPY_INT8:
        _realArray2DToDouble<npy_int8>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    case NPY_INT16:
        _realArray2DToDouble<npy_int16>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    case NPY_INT32:
        _realArray2DToDouble<npy_int32>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;

// prevent the error from Windows compiler MSVC 19:
// `error C2196: case value 'NPY_LONG' already used`
#if NPY_LONG != NPY_INT32

    case NPY_LONG:
        _realArray2DToDouble<npy_long>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
#endif

    // unsigned int
    case NPY_UINT8:
        _realArray2DToDouble<npy_uint8>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    case NPY_UINT16:
        _realArray2DToDouble<npy_uint16>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
    case NPY_UINT32:
        _realArray2DToDouble<npy_uint32>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;

// prevent the error from Windows compiler MSVC 19:
// `error C2196: case value 'NPY_ULONG' already used`
#if NPY_ULONG != NPY_UINT32
    case NPY_ULONG:
        _realArray2DToDouble<npy_ulong>(npArray_ptr, rowIdxMax, colIdxMax, data);
        break;
#endif

    default:
        throw std::runtime_error(errorDescription("PyInterpreter::Numpy: "
                                                  "Conversion of Numpy array of dtype '"
                                                  + std::to_string(npArray_dtype)
                                                  + "' "
                                                    "to vector<double> is not implemented"));
    }

    return data;
}

PyObjectPtr PyInterpreter::Numpy::createArray2DfromC(double* const c_array, const np_size_t dims[2])
{
    if (!c_array) {
        throw std::runtime_error("PyInterpreter::Numpy: "
                                 "Cannot create a Numpy 1D-array from a null data pointer");
    }

    const np_size_t size = dims[0] * dims[1];
    if (size < 1) {
        throw std::runtime_error("PyInterpreter::Numpy: "
                                 "Cannot create a Numpy 1D-array from a data with size = 0");
    }

    npy_intp npDims[2] = {dims[0], dims[1]};

    // create stand-alone Numpy array (float64)
    PyObject* npArray_ptr = PyArray_SimpleNew(/* n_dims */ 2, npDims, NPY_DOUBLE);
    if (!npArray_ptr) {
        checkError();
        throw std::runtime_error("PyInterpreter::Numpy: "
                                 "Cannot create a Numpy 1D-array from the "
                                 "given data (size = "
                                 + std::to_string(size) + ")");
    }

    // obtain pointer to data buffer of Numpy array
    double* array_buffer = static_cast<double*>(PyArray_DATA((PyArrayObject*)(npArray_ptr)));

    for (np_size_t index = 0; index < size; ++index) {
        *array_buffer = c_array[index];
        ++array_buffer;
    }

    // returns a _new_ reference; ie. caller is responsible for the ref-count
    return {npArray_ptr};
}

PyObjectPtr PyInterpreter::Numpy::createArray1DfromC(double* const c_array, const np_size_t size)
{
    if (!c_array) {
        throw std::runtime_error("PyInterpreter::Numpy: "
                                 "Cannot create a Numpy 1D-array from a null data pointer");
    }

    if (size < 1) {
        throw std::runtime_error(
            errorDescription("PyInterpreter::Numpy: "
                             "Cannot create a Numpy 1D-array from a data with size = 0"));
    }

    npy_intp npDims[1] = {size};

    // create stand-alone Numpy array (float64)
    PyObject* npArray_ptr = PyArray_SimpleNew(/* n_dims */ 1, npDims, NPY_DOUBLE);
    if (!npArray_ptr) {
        checkError();
        std::string msg = "PyInterpreter::Numpy: "
                          "Cannot create a Numpy 1D-array from the "
                          "given data (size = "
                          + std::to_string(size) + ")";
        throw std::runtime_error(errorDescription("PyInterpreter::Numpy: "
                                                  "Cannot create a Numpy 1D-array from the "
                                                  "given data (size = "
                                                  + std::to_string(size) + ")"));
    }

    // obtain pointer to data buffer of Numpy array
    double* array_buffer = static_cast<double*>(PyArray_DATA((PyArrayObject*)(npArray_ptr)));

    for (np_size_t index = 0; index < size; ++index) {
        *array_buffer = c_array[index];
        ++array_buffer;
    }

    // returns a _new_ reference; ie. caller is responsible for the ref-count
    return {npArray_ptr};
}


PyObjectPtr PyInterpreter::Numpy::CArrayAsNpy2D(double* const c_array, const np_size_t dims[2])
{
    if (!c_array) {
        throw std::runtime_error(
            errorDescription("PyInterpreter::Numpy: "
                             "Cannot create a Numpy 2D-array from a null data pointer"));
    }

    const np_size_t size = dims[0] * dims[1];
    if (size < 1) {
        throw std::runtime_error(
            errorDescription("PyInterpreter::Numpy: "
                             "Cannot create a Numpy 2D-array from a data with size = 0"));
    }

    npy_intp npDims[2] = {dims[0], dims[1]};

    // convert the contiguous C-array to Numpy array
    PyObject* npArray_ptr = PyArray_SimpleNewFromData(
        /* n_dims */ 2, npDims, NPY_DOUBLE, reinterpret_cast<void*>(c_array));

    if (!npArray_ptr || !PyArray_Check(npArray_ptr)) {
        PyInterpreter::checkError();
        throw std::runtime_error(errorDescription("PyInterpreter::Numpy: Cannot convert the given "
                                                  "C-Array to a Numpy 2D-array"));
    }

    // returns a _new_ reference; ie. caller is responsible for the ref-count
    return {npArray_ptr};
}

PyObjectPtr PyInterpreter::Numpy::arrayND(std::vector<std::size_t>& dimensions)
{
    // descriptors for the array dimensions
    const std::size_t n_dims = dimensions.size();
    if (n_dims < 1) {
        throw std::runtime_error(
            errorDescription("Cannot make a Numpy array with the given number "
                             "of dimensions; number of dimensions must be >= 1"));
    }

    for (std::size_t d = 0; d < n_dims; ++d)
        ASSERT(dimensions[d]);

    npy_int ndim_numpy = static_cast<npy_int>(n_dims);
    auto* ndimsizes_numpy = new npy_intp[n_dims];
    for (std::size_t d = 0; d < n_dims; ++d)
        ndimsizes_numpy[d] = dimensions[d];

    // creating standalone numpy array
    PyObject* npyArray_ptr = PyArray_SimpleNew(ndim_numpy, ndimsizes_numpy, NPY_DOUBLE);
    delete[] ndimsizes_numpy;

    if (!npyArray_ptr) {
        checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter::Numpy: Cannot create a Numpy array"
                             + std::to_string(n_dims) + "D-array from the given data"));
    }

    return {npyArray_ptr};
}

double* PyInterpreter::Numpy::getDataPtr(PyObject* pyobject_ptr)
{
    PyArrayObject* npArray_ptr{reinterpret_cast<PyArrayObject*>(pyobject_ptr)};

    // get the pointer to data buffer of the Numpy array
    double* data_ptr = static_cast<double*>(PyArray_DATA((PyArrayObject*)npArray_ptr));

    if (!data_ptr) {
        checkError();
        throw(std::runtime_error(errorDescription("PyInterpreter::Numpy: "
                                                  "Numpy array has invalid data pointer")));
    }

    return data_ptr;
}


PyObjectPtr PyInterpreter::BornAgain::import(const std::string& path)
{
    if (!path.empty())
        PyInterpreter::addPythonPath(path);

#ifndef _WIN32
    // store ctrl-C handler before Numpy messes it up
    PyOS_sighandler_t sighandler = PyOS_getsig(SIGINT);
#endif

    PyObject* ba_pymodule = PyImport_ImportModule("bornagain");

#ifndef _WIN32
    PyOS_setsig(SIGINT, sighandler); // restore previous ctrl-C handler
#endif

    if (!ba_pymodule || !PyModule_Check(ba_pymodule)) {
        checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter: Cannot load 'bornagain' Python module "
                             "(given path = '"
                             + path + "')"));
    }

    return {ba_pymodule};
}

PyObjectPtr PyInterpreter::BornAgain::importScript(const std::string& script,
                                                   const std::string& path)
{
    PyObjectPtr ba_pymodule{PyInterpreter::BornAgain::import(path)};
    // TODO: Check ba module

    PyObject* pCompiledFn = Py_CompileString(script.c_str(), "", Py_file_input);
    if (!pCompiledFn) {
        ba_pymodule.discard();
        throw std::runtime_error(errorDescription("Cannot compile Python snippet"));
    }

    // create a module
    PyObject* tmpModule = PyImport_ExecCodeModule((char*)"tmpModule", pCompiledFn);
    if (!tmpModule) {
        Py_DecRef(pCompiledFn);
        ba_pymodule.discard();
        throw std::runtime_error(errorDescription("Cannot execute Python snippet"));
    }

    return {tmpModule};
}


std::vector<std::string> PyInterpreter::BornAgain::listOfFunctions(const std::string& script,
                                                                   const std::string& path)
{
    PyInterpreter::Numpy::initialize();

    PyObjectPtr tmpModule{PyInterpreter::BornAgain::importScript(script, path)};

    PyObject* pDict = PyModule_GetDict(tmpModule.get());
    if (!pDict) {
        checkError();
        throw std::runtime_error("PyInterpreter::BornAgain: "
                                 "Cannot obtain the dictionary from the script module");
    }

    PyObject *key, *value;
    Py_ssize_t pos = 0;
    std::vector<std::string> fn_names;
    while (PyDict_Next(pDict, &pos, &key, &value)) {
        if (PyCallable_Check(value)) {
            std::string func_name{PyInterpreter::pyStrtoString(key)};
            if (func_name.find("__") == std::string::npos)
                fn_names.push_back(func_name);
        }
    }
    PyDict_Clear(pDict); // do not accumulate history between imports
    return fn_names;
}


PyObjectPtr PyInterpreter::Fabio::import()
{
    return {PyInterpreter::import("fabio")};
}

PyObjectPtr PyInterpreter::Fabio::open(const std::string& filename, PyObjectPtr& fabio_module)
{
    // load an image via calling `fabio.load` function which takes a
    // filename (Python string) and returns a Numpy array.

    if (!fabio_module.valid() || !PyModule_Check(fabio_module.get())) {
        throw std::runtime_error(errorDescription("PyInterpreter.fabio: Invalid Python module "
                                                  "(expected 'fabio' module)"));
    }

    PyObject* pFunc = PyObject_GetAttrString(fabio_module.get(), (char*)"open");
    if (!pFunc || !PyCallable_Check(pFunc)) {
        PyInterpreter::checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter.fabio: The function 'fabio.open' is not callable"));
    }

    // convert the filename to a Python unicode string
    PyObject* pFilename = PyUnicode_FromString(filename.c_str());
    if (!pFilename) {
        PyInterpreter::checkError();
        throw std::runtime_error(
            errorDescription("PyInterpreter.fabio: Filename '" + filename
                             + "' cannot be converted to Python unicode string"));
    }

    // get the result of the call `fabio.open(<filename>)`
    PyObject* pResult_open = PyObject_CallFunctionObjArgs(pFunc, pFilename, NULL);
    Py_DecRef(pFunc);
    if (!pResult_open) {
        PyInterpreter::checkError();
        std::runtime_error(errorDescription("PyInterpreter.fabio: "
                                            "Invalid return value from calling the function "
                                            "'fabio.open(\""
                                            + filename + "\")'"));
    }

    // get `result.data` (must be a Numpy array)
    PyObject* npyArray_ptr = PyObject_GetAttrString(pResult_open, (char*)"data");
    Py_DecRef(pResult_open);
    if (!npyArray_ptr || !PyArray_Check(npyArray_ptr)) {
        PyInterpreter::checkError();
        std::runtime_error(errorDescription("PyInterpreter.fabio: Invalid return value from "
                                            "calling the function 'fabio.open(\""
                                            + filename + "\")' (expected a Numpy array)"));
    }

    // returns a _new_ reference; ie. caller is responsible for the ref-count
    return {npyArray_ptr};
}
