Skip to content

Commit bfc5a2e

Browse files
committed
Tighten introspection fallbacks and align metadata tests
- add NB_INTROSPECT_SKIP_NB_SIG toggle and raise AttributeError for skipped/incompatible __signature__/__text_signature__ - keep merged annotation/text rendering consistent and update tests/stubs for the new semantics
1 parent 2d9220f commit bfc5a2e

File tree

4 files changed

+144
-205
lines changed

4 files changed

+144
-205
lines changed

src/nb_introspect.cpp

Lines changed: 87 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -11,127 +11,24 @@
1111
# include <cxxabi.h>
1212
#endif
1313

14+
#ifndef NB_INTROSPECT_SKIP_NB_SIG
15+
# define NB_INTROSPECT_SKIP_NB_SIG 1
16+
#endif
17+
1418
NAMESPACE_BEGIN(NB_NAMESPACE)
1519
NAMESPACE_BEGIN(detail)
1620

1721
namespace {
1822

19-
/* This file keeps the PEP 3107/362 plumbing self-contained so nb_func only
20-
exposes the hooks. We translate nanobind's signature metadata directly
21-
into annotations/text signatures without touching nb.__nb_signature__ or
22-
cached Python objects to avoid ABI changes.
23-
24-
Detailed behavior for overloads and how the three PEP-style introspection
25-
outputs are produced:
26-
27-
- Goals
28-
* Provide `__annotations__`, `__text_signature__` and `__signature__`
29-
(inspect.Signature) for nanobind-wrapped callables where possible,
30-
based on internal C++ metadata (`func_data::descr`, `func_data::args`,
31-
`func_data::descr_types`, etc.).
32-
* Avoid touching or replacing the legacy `__nb_signature__` text blob
33-
stored in `func_data::signature` (this keeps backward compatibility).
34-
35-
- High-level flow
36-
1. `collect_signature_metadata()` iterates overloads and calls
37-
`build_signature_metadata()` to produce a `signature_metadata` for each
38-
overload. This captures parameter names, kinds (positional-only,
39-
positional-or-keyword, keyword-only, *args, **kwargs), sanitized
40-
tokens for a compact text signature, and return type text.
41-
2. If `collect_signature_metadata()` fails (returns false), the
42-
introspection functions return `Py_None`. A primary reason for
43-
failure is when an overload is marked with
44-
`func_flags::has_signature` (i.e. the binding author provided an
45-
explicit `nb::sig(...)`/legacy signature string). In that case the
46-
code intentionally refuses to auto-generate PEP-style metadata so as
47-
not to overwrite or conflict with an explicit signature provided by
48-
the author.
49-
3. If collection succeeds, the code checks whether all overloads are
50-
"compatible" via `signatures_are_compatible()`. Compatibility
51-
requires:
52-
- same number of parameters across overloads
53-
- same parameter names and kinds at every position
54-
- identical `sanitized_tokens` sequences (this encodes layout such
55-
as `self`, `*`, `**name`, `/`, default value text, etc.)
56-
57-
- Result rules per introspection output
58-
* `__annotations__` (nb_introspect_annotations)
59-
- If `collect_signature_metadata()` fails: returns `Py_None`.
60-
- If collection succeeds but there are no metas or overloads are
61-
incompatible: returns an empty `dict` (not `None`) to indicate
62-
"we have no typed annotations to provide".
63-
- If collection succeeds and overloads are compatible: merge
64-
parameter annotations and return types across overloads. Merging
65-
logic:
66-
- Collect all distinct annotation strings for each parameter
67-
(only when `has_annotation` is true for that parameter).
68-
- If any annotation is `"typing.Any"`, that wins and the merged
69-
result for that parameter is `"typing.Any"`.
70-
- If there is exactly one distinct annotation, use it.
71-
- If multiple distinct concrete annotations are present, emit
72-
`"typing.Union[T1, T2, ...]"` as the merged annotation string.
73-
- The merged return type is constructed the same way and stored
74-
under the `"return"` key.
75-
76-
* `__text_signature__` (nb_introspect_text_signature)
77-
- If `collect_signature_metadata()` fails: returns `Py_None`.
78-
- If collection succeeds but metas are empty or overloads are
79-
incompatible: returns `Py_None` (no single compact text signature
80-
can be provided).
81-
- If collection succeeds and overloads are compatible: return a
82-
compact text signature string built from the `sanitized_tokens`
83-
of the first meta (compatibility guarantees all metas have the
84-
same `sanitized_tokens`). Example: "(self, arg0, /)".
85-
86-
* `__signature__` (nb_introspect_signature -> inspect.Signature)
87-
- If `collect_signature_metadata()` fails: returns `Py_None`.
88-
- If collection yields no metas: returns `Py_None`.
89-
- If collection succeeds and overloads are compatible: build and
90-
return an `inspect.Signature` object derived from the first
91-
`signature_metadata` (parameters include kinds, default values, and
92-
annotation strings where present).
93-
- If collection succeeds but overloads are *incompatible*: do not
94-
return `Py_None`. Instead, construct and return a fallback
95-
variadic `Signature` equivalent to `(*args, **kwargs)` so that
96-
`inspect.signature()` still returns a usable, permissive signature
97-
object.
98-
99-
- Why `has_signature` causes collection failure
100-
* `func_flags::has_signature` is set when the binding author provided
101-
an explicit signature string via `nb::sig(...)`. That string is
102-
stored in `func_data::signature` and is used for legacy nb-style
103-
signatures and doc rendering (`__nb_signature__`, `nb_func_render_signature`,
104-
etc.). If at least one overload has this flag, `build_signature_metadata`
105-
returns `false` to signal: "do not auto-generate/merge PEP-style
106-
metadata for this callable". In this codebase `Py_None` is used as
107-
the signal value to indicate that automatic PEP introspection was
108-
intentionally skipped.
109-
110-
- Notes and implementation details
111-
* `sanitized_tokens` contains tokens used for text signature merging
112-
(includes param names, '*'/'**' markers, '/' for positional-only
113-
boundary, and default-value text when available). Differences in
114-
sanitized tokens across overloads make them incompatible.
115-
* Default argument text is included in `sanitized_tokens` (so
116-
difference in default values usually prevents merging of text
117-
signatures).
118-
* Annotations are stored/merged as *strings* (e.g. "typing.Any",
119-
"module.Type"), not as evaluated Python objects. This avoids
120-
importing/resolving types at C++ side and keeps ABI surface stable.
121-
* The implementation purposely avoids mutating or replacing the
122-
legacy `__nb_signature__` (stored in `func_data::signature`) so that
123-
existing code relying on that string is not affected.
124-
125-
Summary:
126-
- `collect_signature_metadata()` failure -> all `nb_introspect_*`
127-
return `Py_None` (signal: skip auto-generation).
128-
- collection success + compatible overloads -> produce merged
129-
`__annotations__` (dict), a single `__text_signature__` (str), and a
130-
concrete `inspect.Signature`.
131-
- collection success + incompatible overloads -> annotations = `{}`;
132-
text_signature = `None`; signature = permissive variadic
133-
`inspect.Signature`.
134-
*/
23+
/* PEP 3107/362 helpers:
24+
- NB_INTROSPECT_SKIP_NB_SIG (default 1) controls whether overloads that
25+
used nb::sig skip PEP metadata (1) or are parsed like regular overloads (0).
26+
- Only merge overloads when parameter order, names, kinds, and sanitized
27+
tokens align; otherwise surface empty annotations and None for
28+
__signature__/__text_signature__.
29+
- For compatible overloads, annotations/return types are merged (Union when
30+
multiple concrete values, typing.Any when unspecified) and reused by all
31+
three exported attributes. */
13532

13633
static char *dup_string(const char *s) {
13734
#if defined(_WIN32)
@@ -408,10 +305,13 @@ static PyObject *inspect_kind_object(signature_param_kind kind) {
408305

409306
} // namespace
410307

308+
// Build a single overload's parameter/return description; skip when nb::sig is present.
411309
static bool build_signature_metadata(const func_data *f,
412310
signature_metadata &out) noexcept {
311+
#if NB_INTROSPECT_SKIP_NB_SIG
413312
if (f->flags & (uint32_t) func_flags::has_signature)
414313
return false;
314+
#endif
415315

416316
const bool is_method = f->flags & (uint32_t) func_flags::is_method,
417317
has_args = f->flags & (uint32_t) func_flags::has_args,
@@ -612,8 +512,18 @@ static bool build_signature_metadata(const func_data *f,
612512
return true;
613513
}
614514

615-
static bool collect_signature_metadata(nb_func *func, const func_data *f,
616-
std::vector<signature_metadata> &out) noexcept {
515+
static bool signatures_are_compatible(const std::vector<signature_metadata> &metas);
516+
517+
enum class metadata_state {
518+
skip,
519+
empty,
520+
incompatible,
521+
compatible
522+
};
523+
524+
// Traverse all overloads, classify compatibility, and short-circuit on skip.
525+
static metadata_state collect_signature_metadata(nb_func *func, const func_data *f,
526+
std::vector<signature_metadata> &out) noexcept {
617527
Py_ssize_t overloads = Py_SIZE((PyObject *) func);
618528
if (overloads < 1)
619529
overloads = 1;
@@ -624,13 +534,20 @@ static bool collect_signature_metadata(nb_func *func, const func_data *f,
624534
for (Py_ssize_t i = 0; i < overloads; ++i) {
625535
signature_metadata meta;
626536
if (!build_signature_metadata(f + i, meta))
627-
return false;
537+
return metadata_state::skip;
628538
out.emplace_back(std::move(meta));
629539
}
630540

631-
return true;
541+
if (out.empty())
542+
return metadata_state::empty;
543+
544+
if (!signatures_are_compatible(out))
545+
return metadata_state::incompatible;
546+
547+
return metadata_state::compatible;
632548
}
633549

550+
// Overloads are mergeable only when layout and tokens match exactly.
634551
static bool signatures_are_compatible(const std::vector<signature_metadata> &metas) {
635552
if (metas.empty())
636553
return true;
@@ -765,6 +682,7 @@ static PyObject *build_annotation_dict(const std::vector<signature_metadata> &me
765682
return annotations;
766683
}
767684

685+
// Render the compact text signature string from sanitized tokens.
768686
static PyObject *build_text_signature_from_meta(const signature_metadata &meta) {
769687
std::string signature = "(";
770688
for (size_t i = 0; i < meta.sanitized_tokens.size(); ++i) {
@@ -777,6 +695,7 @@ static PyObject *build_text_signature_from_meta(const signature_metadata &meta)
777695
return PyUnicode_FromString(signature.c_str());
778696
}
779697

698+
// Materialize inspect.Signature using cached inspect objects.
780699
static PyObject *build_signature_object(const signature_metadata &meta) {
781700
if (!ensure_inspect_cache())
782701
return nullptr;
@@ -804,8 +723,18 @@ static PyObject *build_signature_object(const signature_metadata &meta) {
804723
Py_DECREF(param_list);
805724
return nullptr;
806725
}
807-
PyTuple_SET_ITEM(args, 0, name);
808-
PyTuple_SET_ITEM(args, 1, kind);
726+
if (PyTuple_SetItem(args, 0, name) != 0) {
727+
Py_DECREF(kind);
728+
Py_DECREF(args);
729+
Py_DECREF(param_list);
730+
return nullptr;
731+
}
732+
if (PyTuple_SetItem(args, 1, kind) != 0) {
733+
Py_DECREF(kind);
734+
Py_DECREF(args);
735+
Py_DECREF(param_list);
736+
return nullptr;
737+
}
809738

810739
PyObject *kwargs = PyDict_New();
811740
if (!kwargs) {
@@ -847,7 +776,10 @@ static PyObject *build_signature_object(const signature_metadata &meta) {
847776
Py_DECREF(param_list);
848777
return nullptr;
849778
}
850-
PyList_SET_ITEM(param_list, i, param_obj);
779+
if (PyList_SetItem(param_list, i, param_obj) != 0) {
780+
Py_DECREF(param_list);
781+
return nullptr;
782+
}
851783
}
852784

853785
PyObject *parameters_tuple = PyList_AsTuple(param_list);
@@ -899,75 +831,53 @@ static PyObject *build_signature_object(const signature_metadata &meta) {
899831
return result;
900832
}
901833

902-
static signature_metadata build_variadic_signature_metadata() {
903-
signature_metadata meta;
904-
905-
signature_param args;
906-
args.name = "args";
907-
args.annotation.clear();
908-
args.kind = signature_param_kind::var_positional;
909-
args.default_value = nullptr;
910-
args.has_annotation = false;
911-
meta.parameters.push_back(std::move(args));
912-
913-
signature_param kwargs;
914-
kwargs.name = "kwargs";
915-
kwargs.annotation.clear();
916-
kwargs.kind = signature_param_kind::var_keyword;
917-
kwargs.default_value = nullptr;
918-
kwargs.has_annotation = false;
919-
meta.parameters.push_back(std::move(kwargs));
920-
921-
return meta;
834+
static PyObject *return_none() noexcept {
835+
Py_INCREF(Py_None);
836+
return Py_None;
922837
}
923838

839+
// __annotations__: empty dict on skip/incompat, merged dict otherwise.
924840
PyObject *nb_introspect_annotations(nb_func *func, const func_data *f) noexcept {
925841
std::vector<signature_metadata> metas;
926-
if (!collect_signature_metadata(func, f, metas)) {
927-
Py_INCREF(Py_None);
928-
return Py_None;
842+
switch (collect_signature_metadata(func, f, metas)) {
843+
case metadata_state::skip:
844+
case metadata_state::empty:
845+
case metadata_state::incompatible:
846+
return PyDict_New();
847+
case metadata_state::compatible:
848+
return build_annotation_dict(metas);
929849
}
930-
931-
if (metas.empty() || !signatures_are_compatible(metas)) {
932-
PyObject *annotations = PyDict_New();
933-
return annotations;
934-
}
935-
936-
return build_annotation_dict(metas);
850+
return PyDict_New();
937851
}
938852

853+
// __text_signature__: AttributeError on skip/incompat, compact text otherwise.
939854
PyObject *nb_introspect_text_signature(nb_func *func, const func_data *f) noexcept {
940855
std::vector<signature_metadata> metas;
941-
if (!collect_signature_metadata(func, f, metas)) {
942-
Py_INCREF(Py_None);
943-
return Py_None;
944-
}
945-
946-
if (metas.empty() || !signatures_are_compatible(metas)) {
947-
Py_INCREF(Py_None);
948-
return Py_None;
856+
switch (collect_signature_metadata(func, f, metas)) {
857+
case metadata_state::skip:
858+
case metadata_state::empty:
859+
case metadata_state::incompatible:
860+
PyErr_SetString(PyExc_AttributeError, "__text_signature__");
861+
return nullptr;
862+
case metadata_state::compatible:
863+
return build_text_signature_from_meta(metas.front());
949864
}
950-
951-
return build_text_signature_from_meta(metas.front());
865+
return return_none();
952866
}
953867

868+
// __signature__: AttributeError on skip/incompat, inspect.Signature otherwise.
954869
PyObject *nb_introspect_signature(nb_func *func, const func_data *f) noexcept {
955870
std::vector<signature_metadata> metas;
956-
if (!collect_signature_metadata(func, f, metas)) {
957-
Py_INCREF(Py_None);
958-
return Py_None;
959-
}
960-
961-
if (metas.empty()) {
962-
Py_INCREF(Py_None);
963-
return Py_None;
871+
switch (collect_signature_metadata(func, f, metas)) {
872+
case metadata_state::skip:
873+
case metadata_state::empty:
874+
case metadata_state::incompatible:
875+
PyErr_SetString(PyExc_AttributeError, "__signature__");
876+
return nullptr;
877+
case metadata_state::compatible:
878+
return build_signature_object(metas.front());
964879
}
965-
966-
if (signatures_are_compatible(metas))
967-
return build_signature_object(metas.front());
968-
969-
signature_metadata fallback = build_variadic_signature_metadata();
970-
return build_signature_object(fallback);
880+
return return_none();
971881
}
972882

973883
NAMESPACE_END(detail)

tests/test_classes.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -988,15 +988,14 @@ def test51_struct_member_metadata():
988988
method = t.Struct.value_plus
989989
annotations = method.__annotations__
990990
for idx in range(7):
991-
assert annotations[f"arg{idx}"] == "typing.Any"
991+
assert annotations[f"arg{idx}"] == "int"
992992
assert annotations["return"] == "int"
993993
assert method.__text_signature__ == "(self, arg0, arg1, arg2, arg3, arg4, arg5, arg6, /)"
994994
sig = getattr(method, "__signature__", None)
995995
assert sig is not None
996996
assert str(sig) == (
997-
"(self, arg0: 'typing.Any', arg1: 'typing.Any', arg2: 'typing.Any', "
998-
"arg3: 'typing.Any', arg4: 'typing.Any', arg5: 'typing.Any', "
999-
"arg6: 'typing.Any', /) -> 'int'"
997+
"(self, arg0: 'int', arg1: 'int', arg2: 'int', arg3: 'int', "
998+
"arg4: 'int', arg5: 'int', arg6: 'int', /) -> 'int'"
1000999
)
10011000
assert inspect.signature(method) == sig
10021001

@@ -1025,4 +1024,3 @@ def test53_static_method_metadata():
10251024
annotations = method.__annotations__
10261025
assert annotations["arg"] == "typing.Union[int, float]"
10271026
assert annotations["return"] == "int"
1028-

0 commit comments

Comments
 (0)