Skip to content

Commit

Permalink
Strip __signature__ from server and client side metadata (#50)
Browse files Browse the repository at this point in the history
Strip __signature__ from server and client side

For now, pybind11 is our primary binding vehicle, and it doesn't
populate signatures in a way that we can export cleanly. They are just
the first line of the DocString, and that's it. Maybe Nanobind does (or
will do) a better job of metadata transport.

On the client side, parsing these signatures back into signature objects
is hard to do without creating vulnerabilities. I think we would need to
teach the server to export a JSONSchema-like thing rather than expecting
the client side to parse a signature string. Inspect does not currently
work with annotations in __signature__ or __text_signature__.

In both cases, we don't really _need_ signatures for anything and are
relying on a big-ball-of-text __doc__  to transport DocStrings anyhow.
May as well tuck signatures back into the top of this.

I would love to revisit this when the binding side (pybind11/nanobind)
and the client side (inspect) firm up a little.
  • Loading branch information
gsmecher authored Nov 21, 2024
1 parent b381fcd commit c6bc306
Show file tree
Hide file tree
Showing 5 changed files with 41 additions and 49 deletions.
14 changes: 14 additions & 0 deletions include/tuber_support.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ namespace PYBIND11_NAMESPACE {
object type = unique(ctor(**kwargs));
setattr(scope, name, type);
detail::type_caster<T>::bind(type, cpp_entries);

/* Add a custom __repr__ method that shows a string interpretation */
pybind11::setattr(type, "__repr__", pybind11::cpp_function(
[type](pybind11::object self) -> std::string {
T value = self.cast<T>();

for (const auto& item : type.attr("__members__").cast<pybind11::dict>())
if (item.second.cast<T>() == value)
return '"' + item.first.cast<std::string>() + '"';

return "UNKNOWN";
},
pybind11::is_method(type)
));
}

str_enum& value(const char* name, T value) & {
Expand Down
7 changes: 1 addition & 6 deletions tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,11 +435,7 @@ async def test_tuberpy_method_docstrings(resolve):
"""Ensure docstrings in C++ methods end up in the TuberObject's __doc__ dunder."""

s = await resolve("Wrapper")
assert s.increment.__doc__.strip() == tm.Wrapper.increment.__doc__.split("\n", 1)[-1].strip()

# check signature
sig = inspect.signature(s.increment)
assert "x" in sig.parameters
assert s.increment.__doc__.strip() == tm.Wrapper.increment.__doc__.strip()


@pytest.mark.asyncio
Expand Down Expand Up @@ -531,7 +527,6 @@ async def test_tuberpy_serialize_enum_class(resolve):

# Ensure we can round-trip it back into C++
r = await tuber_result(s.is_x(r))

assert r is True


Expand Down
14 changes: 9 additions & 5 deletions tests/test_module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ class Wrapper {

PYBIND11_MODULE(test_module, m) {

py::str_enum<Kind> kind(m, "Kind");
kind.value("X", Kind::X)
.value("Y", Kind::Y);
/* this forced scope ensures Kind is registered before it's used in
* default arguments below. */
{
py::str_enum<Kind> kind(m, "Kind");
kind.value("X", Kind::X)
.value("Y", Kind::Y);
}

auto w = py::class_<Wrapper>(m, "Wrapper")
.def(py::init())
.def("return_x", &Wrapper::return_x)
.def("return_y", &Wrapper::return_y)
.def("is_x", &Wrapper::is_x)
.def("is_y", &Wrapper::is_y)
.def("is_x", &Wrapper::is_x, "k"_a=Kind::X)
.def("is_y", &Wrapper::is_y, "k"_a=Kind::Y)
.def("increment", &Wrapper::increment,
"x"_a,
"A function that increments each element in its argument list.")
Expand Down
42 changes: 14 additions & 28 deletions tuber/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,37 +62,22 @@ def attribute_blacklisted(name: str):
return False


def tuber_wrapper(func: callable, meta: "TuberResult"):
def tuber_wrapper(func: callable, name: str, meta: "TuberResult"):
"""
Annotate the wrapper function with docstrings and signature.
"""

docstring = ""

# Begin with a function signature, if provided and valid
if (sig := getattr(meta, "__signature__", None)) and isinstance(sig, str):
docstring = f"{name}{sig}:\n\n"

# Attach docstring, if provided and valid
try:
func.__doc__ = textwrap.dedent(meta.__doc__)
except:
pass

# Attach a function signature, if provided and valid
try:
# build a dummy function to parse its signature with inspect
code = compile(f"def sigfunc{meta.__signature__}:\n pass", "sigfunc", "single")
exec(code, globals())
sig = inspect.signature(sigfunc)
params = list(sig.parameters.values())
p0 = params[0] if len(params) else None
# add self argument for unbound method
if not p0 or p0.name != "self":
if p0 and p0.kind == inspect.Parameter.POSITIONAL_ONLY:
kind = p0.kind
else:
kind = inspect.Parameter.POSITIONAL_OR_KEYWORD
parself = inspect.Parameter("self", kind)
sig = sig.replace(parameters=[parself] + params)
func.__signature__ = sig
except:
pass
if (doc := getattr(meta, "__doc__", None)) and isinstance(doc, str):
docstring += textwrap.dedent(meta.__doc__)

func.__doc__ = docstring.strip()
return func


Expand Down Expand Up @@ -458,6 +443,7 @@ class SimpleTuberObject:
"""

_context_class = SimpleContext
_tuber_objname = None

def __init__(
self,
Expand Down Expand Up @@ -502,7 +488,7 @@ def __getitem__(self, item: str | int):
def __iter__(self):
try:
return iter(self._items)
except AttributError:
except AttributeError:
raise TypeError(f"'{self._tuber_objname}' object is not iterable")

def object_factory(self, objname: str):
Expand Down Expand Up @@ -545,7 +531,7 @@ def invoke(self, *args, **kwargs):
r = getattr(ctx, name)(*args, **kwargs)
return r.result()

return tuber_wrapper(invoke, meta)
return tuber_wrapper(invoke, name, meta)

def _resolve_object(
self, attr: str | None = None, item: str | int | None = None, meta: "TuberResult" | None = None
Expand Down Expand Up @@ -677,7 +663,7 @@ async def invoke(self, *args, **kwargs):
results = await ctx()
return results[0]

return tuber_wrapper(invoke, meta)
return tuber_wrapper(invoke, name, meta)


# vim: sts=4 ts=4 sw=4 tw=78 smarttab expandtab
13 changes: 3 additions & 10 deletions tuber/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,14 @@ def resolve_method(method):
"""
Return a description of a method.
"""

doc = inspect.getdoc(method)
sig = None

try:
sig = str(inspect.signature(method))
except:
# pybind docstrings include a signature as the first line
if doc and doc.startswith(method.__name__ + "("):
if "\n" in doc:
sig, doc = doc.split("\n", 1)
doc = doc.strip()
else:
sig = doc
doc = None
sig = "(" + sig.split("(", 1)[1]
pass

return dict(__doc__=doc, __signature__=sig)

Expand Down Expand Up @@ -178,7 +172,6 @@ def tuber_meta(self):
return out

def __getattr__(self, name):
print("getattr", name)
return getattr(self._items, name)

def __len__(self):
Expand Down

0 comments on commit c6bc306

Please sign in to comment.