Accessing NETCONF servers from Python

This is pretty basic, but it allows me to read data over NETCONF while
returning nice Python types, and to set leaf values as well. That's
awesome if you ask me.

The "nice Python types" have no way of distinguishing between different
integer sizes. I was afraid that this might be a problem, but according
to the test, it looks that libnetconf2 (and all the C++ layers in front
of that) are happy even when a number is passed in as a string. That's
nice.

This test requires running "alone" with no other sysrepo- or
NETCONF-talking tests in parallel. I do not really know why, but if I'm
running this locally (T460s) with high parallelism, I'm getting failures
about `ietf-netconf-server` module not being recognized. Strange,
perhaps it does not like parallel `sysrepoctl --install` for some
reason?

Finally, the sanitizer handling is super-ugly, but at least it unbreaks
my local workflow. That one also requires an extra fixup touch for
libyang:

diff --git a/src/common.c b/src/common.c
index 7f941196..801a523f 100755
--- a/src/common.c
+++ b/src/common.c
@@ -37,6 +37,8 @@ API LY_ERR *
 ly_errno_glob_address(void)
 {
     FUN_IN;
+    static _Thread_local LY_ERR xx;
+    return &xx;

     return (LY_ERR *)&ly_errno_glob;
 }

I'm not submitting that upstream because I think the old code is valid
as well, and I somehow doubt that upstream  would be thrilled by that.

Change-Id: I4e81f8ba44700747f9bf719fcba2c460e079babd
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 45d0e4d..6b7b3fb 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -285,6 +285,51 @@
 
 endif()
 
+option(WITH_PYTHON_BINDINGS "Create and install Python3 bindings for accessing datastores" OFF)
+if(WITH_PYTHON_BINDINGS)
+    set(PYBIND11_CPP_STANDARD -std=c++17)
+    find_package(pybind11 REQUIRED)
+    pybind11_add_module(netconf_cli_py src/python_netconf.cpp)
+    target_link_libraries(netconf_cli_py PUBLIC netconfaccess)
+
+    if(BUILD_TESTING)
+        configure_file(${CMAKE_CURRENT_SOURCE_DIR}/tests/python_netconfaccess.py
+            ${CMAKE_CURRENT_BINARY_DIR}/tests_python_netconfaccess.py @ONLY)
+        add_test(NAME test_netconf_cli_py COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_BINARY_DIR}/tests_python_netconfaccess.py)
+        set_tests_properties(test_netconf_cli_py PROPERTIES FIXTURES_REQUIRED netopeer_running RESOURCE_LOCK sysrepo DEPENDS test_datastore_access_netconf_cleanup)
+        set_tests_properties(kill_daemons PROPERTIES DEPENDS test_netconf_cli_py)
+
+        set(sanitizer_active OFF)
+        # FIXME: this just sucks. The detection is very unreliable (one could use something like
+        # -fsanitize=address,undefined and we are screwed), and especially clang's query for preload
+        # is obviously unportable because we hardcode host's architecture.
+        # This is super-ugly. Perhaps it would be better just to outright disable everything, but hey,
+        # I need to test this on my laptop where I'm using ASAN by default, and it kinda-almost-works
+        # there with just one patch to libyang :).
+        if (${CMAKE_CXX_FLAGS} MATCHES "-fsanitize=address")
+            set(sanitizer_active ON)
+            set(gcc_sanitizer_preload libasan.so)
+            set(clang_sanitizer_preload libclang_rt.asan-x86_64.so)
+        endif()
+
+        if (sanitizer_active)
+            if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
+                execute_process(COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=${clang_sanitizer_preload}
+                    OUTPUT_VARIABLE LIBxSAN_FULL_PATH OUTPUT_STRIP_TRAILING_WHITESPACE)
+            elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
+                execute_process(COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=${gcc_sanitizer_preload}
+                    OUTPUT_VARIABLE LIBxSAN_FULL_PATH OUTPUT_STRIP_TRAILING_WHITESPACE)
+            else()
+                message(ERROR "Cannot determine correct sanitizer library for LD_PRELOAD")
+            endif()
+            set_property(TEST test_netconf_cli_py APPEND PROPERTY ENVIRONMENT
+                LD_PRELOAD=${LIBxSAN_FULL_PATH}
+                ASAN_OPTIONS=detect_leaks=0 # they look harmless, but they are annoying
+                )
+        endif()
+    endif()
+endif()
+
 if(WITH_DOCS)
     set(doxyfile_in ${CMAKE_CURRENT_SOURCE_DIR}/Doxyfile.in)
     set(doxyfile ${CMAKE_CURRENT_BINARY_DIR}/Doxyfile)
diff --git a/ci/build.sh b/ci/build.sh
index 7ba9954..ba8db7b 100755
--- a/ci/build.sh
+++ b/ci/build.sh
@@ -40,6 +40,16 @@
     export LDFLAGS="-fsanitize=thread ${LDFLAGS}"
 fi
 
+if [[ $ZUUL_JOB_NAME =~ -gcc$ ]]; then
+    # Python and ASAN (and, presumably, all other sanitizers) are tricky to use from a Python DSO,
+    # I was, e.g., getting unrelated failures from libyang's thread-local global access (ly_errno)
+    # even when correctly injecting the ASAN runtime via LD_PRELOAD. Let's just give up and only
+    # enable this when not using sanitizers.
+    # I'm still adding some code to CMakeLists.txt which makes it work locally (Jan's dev environment
+    # with GCC8 and ASAN, Gentoo).
+    CMAKE_OPTIONS="${CMAKE_OPTIONS} -DWITH_PYTHON_BINDINGS=ON"
+fi
+
 PREFIX=~/target
 mkdir ${PREFIX}
 BUILD_DIR=~/build
diff --git a/src/python_netconf.cpp b/src/python_netconf.cpp
new file mode 100644
index 0000000..83964d6
--- /dev/null
+++ b/src/python_netconf.cpp
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2020 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+*/
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+#include "netconf_access.hpp"
+
+using namespace std::literals;
+using namespace pybind11::literals;
+
+// shamelessly stolen from the docs
+namespace pybind11::detail {
+    template <typename... Ts>
+    struct type_caster<boost::variant<Ts...>> : variant_caster<boost::variant<Ts...>> {};
+
+    // Specifies the function used to visit the variant -- `apply_visitor` instead of `visit`
+    template <>
+    struct visit_helper<boost::variant> {
+        template <typename... Args>
+        static auto call(Args &&...args) -> decltype(boost::apply_visitor(args...)) {
+            return boost::apply_visitor(args...);
+        }
+    };
+}
+
+
+PYBIND11_MODULE(netconf_cli_py, m) {
+    m.doc() = "Python bindings for accessing NETCONF servers";
+
+    pybind11::class_<special_>(m, "YangSpecial")
+            .def("__repr__",
+                 [](const special_ s) {
+                    return "<netconf_cli_py.YangSpecial " + specialValueToString(s) + ">";
+                 });
+
+    pybind11::class_<enum_>(m, "YangEnum")
+            .def("__repr__",
+                 [](const enum_ v) {
+                    return "<netconf_cli_py.YangEnum '" + v.m_value + "'>";
+                 });
+
+    pybind11::class_<binary_>(m, "YangBinary")
+            .def("__repr__",
+                 [](const binary_ v) {
+                    return "<netconf_cli_py.YangBinary '" + v.m_value + "'>";
+                 });
+
+    pybind11::class_<identityRef_>(m, "YangIdentityRef")
+            .def("__repr__",
+                 [](const identityRef_ v) {
+                    return "<netconf_cli_py.YangIdentityRef '"s
+                            + (v.m_prefix ? v.m_prefix->m_name + ":" : ""s) + v.m_value + "'>";
+                 });
+
+    pybind11::class_<NetconfAccess>(m, "NetconfAccess")
+            .def(pybind11::init<const std::string&>(), "socketPath"_a)
+            .def("getItems", &NetconfAccess::getItems, "xpath"_a)
+            .def("setLeaf", &NetconfAccess::setLeaf, "xpath"_a, "value"_a)
+            .def("commitChanges", &NetconfAccess::commitChanges)
+            ;
+}
diff --git a/tests/python_netconfaccess.py b/tests/python_netconfaccess.py
new file mode 100644
index 0000000..a4f6c94
--- /dev/null
+++ b/tests/python_netconfaccess.py
@@ -0,0 +1,33 @@
+import netconf_cli_py as nc
+
+c = nc.NetconfAccess(socketPath = "@NETOPEER_SOCKET_PATH@")
+data = c.getItems("/ietf-netconf-server:netconf-server")
+for (k, v) in data.items():
+    print(f"{k}: {type(v)} {v}", flush=True)
+
+if len(data) == 0:
+    print("ERROR: No data returned from NETCONF")
+    exit(1)
+
+hello_timeout_xp = "/ietf-netconf-server:netconf-server/session-options/hello-timeout"
+for EXPECTED in (599, 59, "61"):
+    c.setLeaf(hello_timeout_xp, EXPECTED)
+    c.commitChanges()
+    data = c.getItems(hello_timeout_xp)
+    if (data[hello_timeout_xp] != EXPECTED):
+        if isinstance(EXPECTED, str):
+            if str(data[hello_timeout_xp]) != EXPECTED:
+                print(f"ERROR: hello-timeout not updated (via string) to {EXPECTED}")
+                exit(1)
+        else:
+            print(f"ERROR: hello-timeout not updated to {EXPECTED}")
+            exit(1)
+try:
+    c.setLeaf(hello_timeout_xp, "blesmrt")
+    c.commitChanges()
+    print("ERROR: setting integer to a string did not error out")
+    exit(1)
+except RuntimeError:
+    pass
+
+exit(0)