Skip to content

Commit c071935

Browse files
committed
Added initial testing infrastructure for bindings
1 parent 9b4b3af commit c071935

6 files changed

Lines changed: 376 additions & 0 deletions

File tree

tests/CMakeLists.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
set(TARGET_TEST_MODULE "depthai_pybind11_tests")
3+
4+
# Specify path separator
5+
set(SYS_PATH_SEPARATOR ";")
6+
if(UNIX)
7+
set(SYS_PATH_SEPARATOR ":")
8+
endif()
9+
10+
set(PYBIND11_TEST_FILES
11+
"xlink_exceptions_test.cpp"
12+
)
13+
14+
string(REPLACE ".cpp" ".py" PYBIND11_PYTEST_FILES "${PYBIND11_TEST_FILES}")
15+
16+
# Create the binding library at the end
17+
pybind11_add_module(${TARGET_TEST_MODULE} THIN_LTO ${TARGET_TEST_MODULE}.cpp ${PYBIND11_TEST_FILES})
18+
19+
# A single command to compile and run the tests
20+
add_custom_target(
21+
pytest COMMAND
22+
${CMAKE_COMMAND} -E env
23+
# Python path (to find compiled modules)
24+
"PYTHONPATH=$<TARGET_FILE_DIR:${TARGET_NAME}>${SYS_PATH_SEPARATOR}$<TARGET_FILE_DIR:${TARGET_TEST_MODULE}>${SYS_PATH_SEPARATOR}$ENV{PYTHONPATH}"
25+
# ASAN in case of sanitizers
26+
${ASAN_ENVIRONMENT_VARS}
27+
${PYTHON_EXECUTABLE} -m pytest ${PYBIND11_PYTEST_FILES}
28+
DEPENDS
29+
${TARGET_TEST_MODULE} # Compiled tests
30+
${TARGET_NAME} # DepthAI Python Library
31+
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
32+
USES_TERMINAL
33+
)
34+
35+
# Link to depthai
36+
target_link_libraries(${TARGET_TEST_MODULE} PRIVATE pybind11::pybind11 depthai::core)

tests/conftest.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# -*- coding: utf-8 -*-
2+
"""pytest configuration
3+
4+
Extends output capture as needed by pybind11: ignore constructors, optional unordered lines.
5+
Adds docstring and exceptions message sanitizers: ignore Python 2 vs 3 differences.
6+
"""
7+
8+
import contextlib
9+
import difflib
10+
import gc
11+
import re
12+
import textwrap
13+
14+
import pytest
15+
16+
# Early diagnostic for failed imports
17+
import depthai_pybind11_tests # noqa: F401
18+
19+
_unicode_marker = re.compile(r"u(\'[^\']*\')")
20+
_long_marker = re.compile(r"([0-9])L")
21+
_hexadecimal = re.compile(r"0x[0-9a-fA-F]+")
22+
23+
def _strip_and_dedent(s):
24+
"""For triple-quote strings"""
25+
return textwrap.dedent(s.lstrip("\n").rstrip())
26+
27+
28+
def _split_and_sort(s):
29+
"""For output which does not require specific line order"""
30+
return sorted(_strip_and_dedent(s).splitlines())
31+
32+
33+
def _make_explanation(a, b):
34+
"""Explanation for a failed assert -- the a and b arguments are List[str]"""
35+
return ["--- actual / +++ expected"] + [
36+
line.strip("\n") for line in difflib.ndiff(a, b)
37+
]
38+
39+
40+
class Output(object):
41+
"""Basic output post-processing and comparison"""
42+
43+
def __init__(self, string):
44+
self.string = string
45+
self.explanation = []
46+
47+
def __str__(self):
48+
return self.string
49+
50+
def __eq__(self, other):
51+
# Ignore constructor/destructor output which is prefixed with "###"
52+
a = [
53+
line
54+
for line in self.string.strip().splitlines()
55+
if not line.startswith("###")
56+
]
57+
b = _strip_and_dedent(other).splitlines()
58+
if a == b:
59+
return True
60+
else:
61+
self.explanation = _make_explanation(a, b)
62+
return False
63+
64+
65+
class Unordered(Output):
66+
"""Custom comparison for output without strict line ordering"""
67+
68+
def __eq__(self, other):
69+
a = _split_and_sort(self.string)
70+
b = _split_and_sort(other)
71+
if a == b:
72+
return True
73+
else:
74+
self.explanation = _make_explanation(a, b)
75+
return False
76+
77+
78+
class Capture(object):
79+
def __init__(self, capfd):
80+
self.capfd = capfd
81+
self.out = ""
82+
self.err = ""
83+
84+
def __enter__(self):
85+
self.capfd.readouterr()
86+
return self
87+
88+
def __exit__(self, *args):
89+
self.out, self.err = self.capfd.readouterr()
90+
91+
def __eq__(self, other):
92+
a = Output(self.out)
93+
b = other
94+
if a == b:
95+
return True
96+
else:
97+
self.explanation = a.explanation
98+
return False
99+
100+
def __str__(self):
101+
return self.out
102+
103+
def __contains__(self, item):
104+
return item in self.out
105+
106+
@property
107+
def unordered(self):
108+
return Unordered(self.out)
109+
110+
@property
111+
def stderr(self):
112+
return Output(self.err)
113+
114+
115+
@pytest.fixture
116+
def capture(capsys):
117+
"""Extended `capsys` with context manager and custom equality operators"""
118+
return Capture(capsys)
119+
120+
121+
class SanitizedString(object):
122+
def __init__(self, sanitizer):
123+
self.sanitizer = sanitizer
124+
self.string = ""
125+
self.explanation = []
126+
127+
def __call__(self, thing):
128+
self.string = self.sanitizer(thing)
129+
return self
130+
131+
def __eq__(self, other):
132+
a = self.string
133+
b = _strip_and_dedent(other)
134+
if a == b:
135+
return True
136+
else:
137+
self.explanation = _make_explanation(a.splitlines(), b.splitlines())
138+
return False
139+
140+
141+
def _sanitize_general(s):
142+
s = s.strip()
143+
s = s.replace("pybind11_tests.", "m.")
144+
s = s.replace("unicode", "str")
145+
s = _long_marker.sub(r"\1", s)
146+
s = _unicode_marker.sub(r"\1", s)
147+
return s
148+
149+
150+
def _sanitize_docstring(thing):
151+
s = thing.__doc__
152+
s = _sanitize_general(s)
153+
return s
154+
155+
156+
@pytest.fixture
157+
def doc():
158+
"""Sanitize docstrings and add custom failure explanation"""
159+
return SanitizedString(_sanitize_docstring)
160+
161+
162+
def _sanitize_message(thing):
163+
s = str(thing)
164+
s = _sanitize_general(s)
165+
s = _hexadecimal.sub("0", s)
166+
return s
167+
168+
169+
@pytest.fixture
170+
def msg():
171+
"""Sanitize messages and add custom failure explanation"""
172+
return SanitizedString(_sanitize_message)
173+
174+
175+
# noinspection PyUnusedLocal
176+
def pytest_assertrepr_compare(op, left, right):
177+
"""Hook to insert custom failure explanation"""
178+
if hasattr(left, "explanation"):
179+
return left.explanation
180+
181+
182+
@contextlib.contextmanager
183+
def suppress(exception):
184+
"""Suppress the desired exception"""
185+
try:
186+
yield
187+
except exception:
188+
pass
189+
190+
191+
def gc_collect():
192+
"""Run the garbage collector twice (needed when running
193+
reference counting tests with PyPy)"""
194+
gc.collect()
195+
gc.collect()
196+
197+
198+
def pytest_configure():
199+
pytest.suppress = suppress
200+
pytest.gc_collect = gc_collect

tests/depthai_pybind11_tests.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
tests/pybind11_tests.cpp -- pybind example plugin
3+
4+
Copyright (c) 2016 Wenzel Jakob <wenzel.jakob@epfl.ch>
5+
6+
All rights reserved. Use of this source code is governed by a
7+
BSD-style license that can be found in the LICENSE file.
8+
*/
9+
10+
#include "depthai_pybind11_tests.hpp"
11+
12+
#include <functional>
13+
#include <list>
14+
15+
/*
16+
For testing purposes, we define a static global variable here in a function that each individual
17+
test .cpp calls with its initialization lambda. It's convenient here because we can just not
18+
compile some test files to disable/ignore some of the test code.
19+
20+
It is NOT recommended as a way to use pybind11 in practice, however: the initialization order will
21+
be essentially random, which is okay for our test scripts (there are no dependencies between the
22+
individual pybind11 test .cpp files), but most likely not what you want when using pybind11
23+
productively.
24+
25+
Instead, see the "How can I reduce the build time?" question in the "Frequently asked questions"
26+
section of the documentation for good practice on splitting binding code over multiple files.
27+
*/
28+
std::list<std::function<void(py::module_ &)>> &initializers() {
29+
static std::list<std::function<void(py::module_ &)>> inits;
30+
return inits;
31+
}
32+
33+
test_initializer::test_initializer(Initializer init) {
34+
initializers().emplace_back(init);
35+
}
36+
37+
test_initializer::test_initializer(const char *submodule_name, Initializer init) {
38+
initializers().emplace_back([=](py::module_ &parent) {
39+
auto m = parent.def_submodule(submodule_name);
40+
init(m);
41+
});
42+
}
43+
44+
PYBIND11_MODULE(depthai_pybind11_tests, m) {
45+
m.doc() = "depthai pybind11 test module";
46+
47+
#if !defined(NDEBUG)
48+
m.attr("debug_enabled") = true;
49+
#else
50+
m.attr("debug_enabled") = false;
51+
#endif
52+
53+
for (const auto &initializer : initializers())
54+
initializer(m);
55+
}

tests/depthai_pybind11_tests.hpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#pragma once
2+
3+
// This must be kept first for MSVC 2015.
4+
// Do not remove the empty line between the #includes.
5+
#include <pybind11/pybind11.h>
6+
7+
#include <pybind11/eval.h>
8+
9+
#if defined(_MSC_VER) && _MSC_VER < 1910
10+
// We get some really long type names here which causes MSVC 2015 to emit warnings
11+
# pragma warning( \
12+
disable : 4503) // warning C4503: decorated name length exceeded, name was truncated
13+
#endif
14+
15+
namespace py = pybind11;
16+
using namespace pybind11::literals;
17+
18+
class test_initializer {
19+
using Initializer = void (*)(py::module_ &);
20+
21+
public:
22+
test_initializer(Initializer init);
23+
test_initializer(const char *submodule_name, Initializer init);
24+
};
25+
26+
#define TEST_SUBMODULE(name, variable) \
27+
void test_submodule_##name(py::module_ &); \
28+
test_initializer name(#name, test_submodule_##name); \
29+
void test_submodule_##name(py::module_ &(variable))

tests/xlink_exceptions_test.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#include "depthai_pybind11_tests.hpp"
2+
3+
#include "depthai/depthai.hpp"
4+
5+
TEST_SUBMODULE(xlink_exceptions, m) {
6+
7+
m.def("throw_xlink_error", [](){
8+
throw dai::XLinkError(X_LINK_ERROR, "stream", "Yikes!");
9+
});
10+
11+
m.def("throw_xlink_read_error", [](){
12+
throw dai::XLinkReadError(X_LINK_ERROR, "stream_read");
13+
});
14+
15+
m.def("throw_xlink_write_error", [](){
16+
throw dai::XLinkWriteError(X_LINK_ERROR, "stream_write");
17+
});
18+
19+
}

tests/xlink_exceptions_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# -*- coding: utf-8 -*-
2+
import sys
3+
4+
import pytest
5+
6+
import depthai as dai
7+
from depthai_pybind11_tests import xlink_exceptions as m
8+
9+
def test_xlink_error_exception(msg):
10+
with pytest.raises(dai.XLinkError) as excinfo:
11+
m.throw_xlink_error()
12+
assert msg(excinfo.value) == "Yikes!"
13+
14+
def test_xlink_read_error_exception(msg):
15+
with pytest.raises(dai.XLinkReadError) as excinfo:
16+
m.throw_xlink_read_error()
17+
assert msg(excinfo.value) == "Couldn't read data from stream: 'stream_read' (X_LINK_ERROR)"
18+
19+
def test_xlink_write_error_exception(msg):
20+
with pytest.raises(dai.XLinkWriteError) as excinfo:
21+
m.throw_xlink_write_error()
22+
assert msg(excinfo.value) == "Couldn't write data to stream: 'stream_write' (X_LINK_ERROR)"
23+
24+
def test_xlink_error_exception_runtime_error(msg):
25+
with pytest.raises(RuntimeError) as excinfo:
26+
m.throw_xlink_error()
27+
assert msg(excinfo.value) == "Yikes!"
28+
29+
def test_xlink_read_error_exception_xlink_error(msg):
30+
with pytest.raises(dai.XLinkError) as excinfo:
31+
m.throw_xlink_read_error()
32+
assert msg(excinfo.value) == "Couldn't read data from stream: 'stream_read' (X_LINK_ERROR)"
33+
34+
def test_xlink_write_error_exception_xlink_error(msg):
35+
with pytest.raises(dai.XLinkError) as excinfo:
36+
m.throw_xlink_write_error()
37+
assert msg(excinfo.value) == "Couldn't write data to stream: 'stream_write' (X_LINK_ERROR)"

0 commit comments

Comments
 (0)