diff --git a/bindings/pydrake/BUILD.bazel b/bindings/pydrake/BUILD.bazel index 40e2cc4e9a7c..d9bf0ef0a96e 100644 --- a/bindings/pydrake/BUILD.bazel +++ b/bindings/pydrake/BUILD.bazel @@ -49,7 +49,7 @@ drake_pybind_library( cc_srcs = ["common_py.cc"], package_info = PACKAGE_INFO, py_deps = [ - ":util_py", + "//bindings/pydrake/util:module_shim_py", ], py_srcs = [ "__init__.py", diff --git a/bindings/pydrake/util/BUILD.bazel b/bindings/pydrake/util/BUILD.bazel index fc8afdfc44fe..5f959df92f0c 100644 --- a/bindings/pydrake/util/BUILD.bazel +++ b/bindings/pydrake/util/BUILD.bazel @@ -9,6 +9,7 @@ load( ) load( "//tools/skylark:pybind.bzl", + "drake_pybind_cc_googletest", "get_drake_pybind_installs", "get_pybind_package_info", ) @@ -55,6 +56,7 @@ drake_py_library( PYBIND_LIBRARIES = [] PY_LIBRARIES = [ + ":cpp_types_py", ":module_py", ":module_shim_py", ] @@ -66,6 +68,25 @@ drake_py_library( deps = PYBIND_LIBRARIES + PY_LIBRARIES, ) +# ODR does not matter, because the singleton will be stored in Python. +drake_cc_library( + name = "cpp_types_pybind", + srcs = ["cpp_types_pybind.cc"], + hdrs = ["cpp_types_pybind.h"], + deps = [ + ":type_pack", + "@pybind11", + ], +) + +drake_py_library( + name = "cpp_types_py", + srcs = ["cpp_types.py"], + deps = [ + ":module_py", + ], +) + install( name = "install", targets = PY_LIBRARIES, @@ -100,4 +121,17 @@ drake_py_test( ], ) +drake_py_test( + name = "cpp_types_test", + deps = [ + ":cpp_types_py", + ], +) + +drake_pybind_cc_googletest( + name = "cpp_types_pybind_test", + cc_deps = [":cpp_types_pybind"], + py_deps = [":cpp_types_py"], +) + add_lint_tests() diff --git a/bindings/pydrake/util/cpp_types.py b/bindings/pydrake/util/cpp_types.py new file mode 100644 index 000000000000..3405adfe20de --- /dev/null +++ b/bindings/pydrake/util/cpp_types.py @@ -0,0 +1,84 @@ +from __future__ import absolute_import, print_function + +import ctypes +import numpy as np + +""" +@file +Defines a mapping between Python and alias types, and provides canonical Python +types as they relate to C++. +""" + + +def _get_type_name(t): + # Gets type name as a string. + if t.__module__ != "__builtin__": + return t.__module__ + "." + t.__name__ + else: + return t.__name__ + + +class _StrictMap(object): + # Provides a map which may only add a key once. + def __init__(self): + self._values = dict() + + def add(self, key, value): + assert key not in self._values, "Already added: {}".format(key) + self._values[key] = value + + def get(self, key, default): + return self._values.get(key, default) + + +class _AliasRegistry(object): + # Registers aliases for a set of objects. This will be used for template + # parameters. + def __init__(self): + self._to_canonical = _StrictMap() + self._register_common() + + def _register_common(self): + # Register common Python aliases relevant for C++. + self.register(float, ctypes.c_double, np.double) + self.register(np.float32, ctypes.c_float) + self.register(int, np.int32, ctypes.c_int32) + self.register(np.uint32, ctypes.c_uint32) + + def register(self, canonical, *aliases): + # Registers a set of aliases to a canonical value. + for alias in aliases: + self._to_canonical.add(alias, canonical) + + def get_canonical(self, alias, default_same=True): + # Gets registered canonical type if there is a mapping; otherwise + # return default (same if `default_same`, or None otherwise). + default = alias + if not default_same: + default = None + return self._to_canonical.get(alias, default) + + def get_name(self, alias): + # Gets string for an alias. + canonical = self.get_canonical(alias) + if isinstance(canonical, type): + return _get_type_name(canonical) + else: + # For literals. + return str(canonical) + + +# Create singleton instance. +_aliases = _AliasRegistry() + + +def get_types_canonical(param): + """Gets the canonical types for a set of Python types (canonical as in + how they relate to C++ types. """ + return tuple(map(_aliases.get_canonical, param)) + + +def get_type_names(param): + """Gets the canonical type names for a set of Python types (canonical as in + how they relate to C++ types. """ + return tuple(map(_aliases.get_name, param)) diff --git a/bindings/pydrake/util/cpp_types_pybind.cc b/bindings/pydrake/util/cpp_types_pybind.cc new file mode 100644 index 000000000000..a9baa8dc339f --- /dev/null +++ b/bindings/pydrake/util/cpp_types_pybind.cc @@ -0,0 +1,74 @@ +#include "drake/bindings/pydrake/util/cpp_types_pybind.h" + +#include + +namespace drake { +namespace pydrake { +namespace internal { +namespace { + +// Creates a Python object that should uniquely hash for a primitive C++ +// type. +py::object GetPyHash(const std::type_info& tinfo) { + return py::make_tuple("cpp_type", tinfo.hash_code()); +} + +// Registers C++ type. +template +void RegisterType( + py::module m, py::object aliases, const std::string& canonical_str) { + // Create an object that is a unique hash. + py::object canonical = py::eval(canonical_str, m.attr("__dict__")); + py::object alias = GetPyHash(typeid(T)); + aliases.attr("register")(canonical, alias); +} + +// Registers common C++ types. +void RegisterCommon(py::module m, py::object aliases) { + // Make mappings for C++ RTTI to Python types. + // Unfortunately, this is hard to obtain from `pybind11`. + RegisterType(m, aliases, "bool"); + RegisterType(m, aliases, "str"); + RegisterType(m, aliases, "float"); + RegisterType(m, aliases, "np.float32"); + RegisterType(m, aliases, "int"); + RegisterType(m, aliases, "np.uint32"); + RegisterType(m, aliases, "np.int64"); + // For supporting generic Python types. + RegisterType(m, aliases, "object"); +} + +} // namespace + +py::object GetTypeAliases() { + py::module m = py::module::import("pydrake.util.cpp_types"); + py::object aliases = m.attr("_aliases"); + const char registered_check[] = "_register_common_cpp"; + if (!py::hasattr(m, registered_check)) { + RegisterCommon(m, aliases); + m.attr(registered_check) = true; + } + return aliases; +} + +py::object GetPyTypeImpl(const std::type_info& tinfo) { + py::object canonical = + GetTypeAliases().attr("get_canonical")(GetPyHash(tinfo), false); + if (!canonical.is_none()) { + return canonical; + } else { + // If this C++ type was not explicitly registered above, then attempt + // to get the pybind, erroring out if it's not registered. + // WARNING: Internal API :( + auto* info = py::detail::get_type_info(tinfo); + if (!info) { + throw std::runtime_error("Unknown custom type"); + } + py::handle h(reinterpret_cast(info->type)); + return py::reinterpret_borrow(h); + } +} + +} // namespace internal +} // namespace pydrake +} // namespace drake diff --git a/bindings/pydrake/util/cpp_types_pybind.h b/bindings/pydrake/util/cpp_types_pybind.h new file mode 100644 index 000000000000..33653888e9b1 --- /dev/null +++ b/bindings/pydrake/util/cpp_types_pybind.h @@ -0,0 +1,56 @@ +#pragma once + +/// @file +/// Provides a mechanism to map C++ types to canonical Python types. + +#include +#include +#include + +#include + +#include "drake/bindings/pydrake/util/type_pack.h" + +namespace py = pybind11; + +namespace drake { +namespace pydrake { +namespace internal { + +// Gets singleton for type aliases from `cpp_types`. +py::object GetTypeAliases(); + +// Gets Python type object given `std::type_info`. +py::object GetPyTypeImpl(const std::type_info& tinfo); + +// Gets Python type for a C++ type (base case). +template +inline py::object GetPyTypeImpl(type_pack = {}) { + return GetPyTypeImpl(typeid(T)); +} + +// Gets Python literal for a C++ literal (specialization). +template +inline py::object GetPyTypeImpl( + type_pack> = {}) { + return py::cast(Value); +} + +} // namespace internal + +/// Gets the canonical Python type for a given C++ type. +template +inline py::object GetPyType(type_pack tag = {}) { + // Explicitly provide `tag` so that inference can handle the different + // cases. + return internal::GetPyTypeImpl(tag); +} + +/// Gets the canonical Python types for each C++ type. +template +inline py::tuple GetPyTypes(type_pack = {}) { + return py::make_tuple(GetPyType()...); +} + +} // namespace pydrake +} // namespace drake diff --git a/bindings/pydrake/util/test/cpp_types_pybind_test.cc b/bindings/pydrake/util/test/cpp_types_pybind_test.cc new file mode 100644 index 000000000000..2a8f52ec1bcf --- /dev/null +++ b/bindings/pydrake/util/test/cpp_types_pybind_test.cc @@ -0,0 +1,74 @@ +#include "drake/bindings/pydrake/util/cpp_types_pybind.h" + +// @file +// Tests the public interfaces in `cpp_types.py` and `cpp_types_pybind.h`. + +#include + +#include +#include +#include +#include + +using std::string; + +namespace drake { +namespace pydrake { + +bool PyEquals(py::object lhs, py::object rhs) { + return lhs.attr("__eq__")(rhs).cast(); +} + +template +bool CheckPyType(const string& py_expr_expected, type_pack = {}) { + py::object actual = GetPyType(); + py::object expected = py::eval(py_expr_expected.c_str()); + return actual.is(expected); +} + +template +using constant = std::integral_constant; + +struct CustomCppType {}; + +GTEST_TEST(CppTypesTest, InCpp) { + // Define custom class only once here. + py::module m("__main__"); + py::globals()["np"] = py::module::import("numpy"); + + py::class_(m, "CustomCppType"); + + // Check C++ behavior. + ASSERT_TRUE(CheckPyType("bool")); + ASSERT_TRUE(CheckPyType("str")); + ASSERT_TRUE(CheckPyType("float")); + ASSERT_TRUE(CheckPyType("np.float32")); + ASSERT_TRUE(CheckPyType("int")); + ASSERT_TRUE(CheckPyType("object")); + + // Custom types. + ASSERT_TRUE(CheckPyType("CustomCppType")); + + // Literals parameters. + ASSERT_TRUE(CheckPyType("True")); + ASSERT_TRUE((CheckPyType>("-1"))); + ASSERT_TRUE((CheckPyType>("1"))); + + // Packs / tuples. + ASSERT_TRUE(PyEquals(GetPyTypes(), py::eval("int, bool"))); +} + +int main(int argc, char** argv) { + // Reconstructing `scoped_interpreter` multiple times (e.g. via `SetUp()`) + // while *also* importing `numpy` wreaks havoc. + py::scoped_interpreter guard; + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +} // namespace pydrake +} // namespace drake + +int main(int argc, char** argv) { + return drake::pydrake::main(argc, argv); +} diff --git a/bindings/pydrake/util/test/cpp_types_test.py b/bindings/pydrake/util/test/cpp_types_test.py new file mode 100644 index 000000000000..06cc5b492141 --- /dev/null +++ b/bindings/pydrake/util/test/cpp_types_test.py @@ -0,0 +1,47 @@ +from __future__ import print_function + +import unittest +import ctypes +import numpy as np + + +from pydrake.util.cpp_types import get_types_canonical, get_type_names + + +class CustomPyType(object): + pass + + +class TestCppTypes(unittest.TestCase): + def _check_alias(self, alias, canonical): + actual = get_types_canonical([alias])[0] + self.assertTrue(actual is canonical) + + def _check_name(self, alias, name): + actual = get_type_names([alias])[0] + self.assertEquals(actual, name) + + def test_idempotent(self): + # One-to-one. + self._check_alias(bool, bool) + self._check_alias(str, str) + self._check_alias(int, int) + self._check_alias(float, float) + self._check_alias(object, object) + self._check_name(int, "int") + # - Custom Types. + self._check_alias(CustomPyType, CustomPyType) + self._check_name(CustomPyType, __name__ + ".CustomPyType") + # - Literals. + self._check_alias(1, 1) + self._check_name(1, "1") + + def test_aliases(self): + # Aliases: + self._check_alias(np.double, float) + self._check_alias(ctypes.c_int32, int) + self._check_name(ctypes.c_int32, "int") + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/skylark/BUILD.bazel b/tools/skylark/BUILD.bazel index 8cd08f4220eb..42f319ca774b 100644 --- a/tools/skylark/BUILD.bazel +++ b/tools/skylark/BUILD.bazel @@ -4,4 +4,8 @@ package(default_visibility = ["//visibility:public"]) load("//tools/lint:lint.bzl", "add_lint_tests") +exports_files([ + "py_env_runner.py", +]) + add_lint_tests() diff --git a/tools/skylark/py_env_runner.py b/tools/skylark/py_env_runner.py new file mode 100644 index 000000000000..2fe16e748bfd --- /dev/null +++ b/tools/skylark/py_env_runner.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +""" +Wrapper Python script to ensure we can execute a C++ binary with access to +Python libraries using an environment established by Bazel. +""" + +# TODO(eric.cousineau): See if there is a way to do this in pure C++, such +# that it is easier to debug. + +import subprocess +import sys + +assert len(sys.argv) >= 2 +subprocess.check_call(sys.argv[1:]) diff --git a/tools/skylark/pybind.bzl b/tools/skylark/pybind.bzl index e66be9579cab..c3ac4a0b20a5 100644 --- a/tools/skylark/pybind.bzl +++ b/tools/skylark/pybind.bzl @@ -5,10 +5,12 @@ load("@drake//tools/install:install.bzl", "install") load( "//tools:drake.bzl", "drake_cc_binary", + "drake_cc_googletest", ) load( "//tools/skylark:drake_py.bzl", "drake_py_library", + "drake_py_test", ) load("//tools/skylark:6996.bzl", "adjust_label_for_drake_hoist") @@ -197,3 +199,52 @@ def _get_package_info(base_package, sub_package = None): base_path_rel = base_path_rel, # Sub-package's path relative to base package's path. sub_path_rel = sub_path_rel) + +def drake_pybind_cc_googletest( + name, + cc_srcs = [], + py_deps = [], + cc_deps = [], + args = [], + visibility = None, + tags = []): + """Defines a C++ test (using `pybind`) which has access to Python + libraries. """ + cc_name = name + "_cc" + if not cc_srcs: + cc_srcs = ["test/{}.cc".format(name)] + drake_cc_googletest( + name = cc_name, + srcs = cc_srcs, + deps = cc_deps + [ + "//tools/install/libdrake:drake_shared_library", + "@pybind11", + ], + # Add 'manual', because we only want to run it with Python present. + tags = ["manual"], + visibility = visibility, + ) + + py_name = name + "_py" + # Expose as library, to make it easier to expose Bazel environment for + # external tools. + drake_py_library( + name = py_name, + deps = py_deps, + testonly = 1, + visibility = visibility, + ) + + # Use this Python test as the glue for Bazel to expose the appropriate + # environment for the C++ binary. + py_main = "//tools/skylark:py_env_runner.py" + drake_py_test( + name = name, + srcs = [py_main], + main = py_main, + data = [cc_name], + args = ["$(location {})".format(cc_name)] + args, + deps = [py_name], + tags = tags, + visibility = visibility, + )