fix: property docstrings missing self (#6252)

* Mark lambda properties as docstrings

Pybind11 does not mark properties as is_method leading to self in the docstring being arg0

* style: pre-commit fixes

* Add constructor

* style: pre-commit fixes

* Add tests

* style: pre-commit fixes

* Replace nullptr constructors with template

* De-duplicate docstrings

* Add lambda and cpp_function tests

* style: pre-commit fixes

* Fix lambda setter

* fix: avoid nullptr ambiguity in cpp_function, xfail cpp_function docstring

The lambda constructor accepted std::nullptr_t, which made calls with
nullptr and extras ambiguous on GCC 8/9. Also update the late-arg strdup
comment, and mark the user-supplied cpp_function docstring case as a
known limitation.

Assisted-by: ClaudeCode:claude-opus-5-5

* fix: merge nullptr cpp_function constructors, split xfail test

Fold cpp_function(std::nullptr_t) into the variadic overload with a
NOLINT for google-explicit-constructor, which clang-tidy flagged. Test
the getter and setter cpp_function docstrings separately.

Assisted-by: ClaudeCode:claude-opus-5-5

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h
index e8bcadd..2459a12 100644
--- a/include/pybind11/pybind11.h
+++ b/include/pybind11/pybind11.h
@@ -143,9 +143,9 @@
 class cpp_function : public function {
 public:
     cpp_function() = default;
+    template <typename... Extra>
     // NOLINTNEXTLINE(google-explicit-constructor)
-    cpp_function(std::nullptr_t) {}
-    cpp_function(std::nullptr_t, const is_setter &) {}
+    cpp_function(std::nullptr_t, const Extra &...) {}
 
     /// Construct a cpp_function from a vanilla function pointer
     template <typename Return, typename... Args, typename... Extra>
@@ -157,7 +157,9 @@
     /// Construct a cpp_function from a lambda function (possibly with internal state)
     template <typename Func,
               typename... Extra,
-              typename = detail::enable_if_t<detail::is_lambda<Func>::value>>
+              typename = detail::enable_if_t<
+                  detail::is_lambda<Func>::value
+                  && !detail::is_same_ignoring_cvref<Func, std::nullptr_t>::value>>
     // NOLINTNEXTLINE(google-explicit-constructor)
     cpp_function(Func &&f, const Extra &...extra) {
         initialize(
@@ -1585,7 +1587,7 @@
     template <typename Getter, typename... Extra>
     class_ &def_property_readonly(const char *name, const Getter &fget, const Extra &...extra) {
         return def_property_readonly(name,
-                                     cpp_function(method_adaptor<type>(fget)),
+                                     cpp_function(method_adaptor<type>(fget), is_method(*this)),
                                      return_value_policy::reference_internal,
                                      extra...);
     }
@@ -1618,7 +1620,10 @@
     class_ &
     def_property(const char *name, const Getter &fget, const Setter &fset, const Extra &...extra) {
         return def_property(
-            name, fget, cpp_function(method_adaptor<type>(fset), is_setter()), extra...);
+            name,
+            fget,
+            cpp_function(method_adaptor<type>(fset), is_setter(), is_method(*this)),
+            extra...);
     }
     template <typename Getter, typename... Extra>
     class_ &def_property(const char *name,
@@ -1626,7 +1631,7 @@
                          const cpp_function &fset,
                          const Extra &...extra) {
         return def_property(name,
-                            cpp_function(method_adaptor<type>(fget)),
+                            cpp_function(method_adaptor<type>(fget), is_method(*this)),
                             fset,
                             return_value_policy::reference_internal,
                             extra...);
@@ -1676,10 +1681,10 @@
                 std::free(doc_prev);
                 rec_fget->doc = PYBIND11_COMPAT_STRDUP(rec_fget->doc);
             }
-            // Args added by process_attributes (e.g. "self" via is_method + pos_only/kw_only)
-            // need their strings strdup'd: initialize_generic's strdup loop already ran during
-            // cpp_function construction, so it won't process these late additions. Without this,
-            // destruct() would call free() on string literals. See gh-5976.
+            // process_attributes can add args after construction, e.g. "self" from is_method +
+            // pos_only/kw_only when a user-supplied cpp_function lacks is_method. The strdup loop
+            // in initialize_generic already ran, so strdup them here, or destruct() would free()
+            // string literals. See gh-5976.
             for (auto i = args_before; i < rec_fget->args.size(); ++i) {
                 if (rec_fget->args[i].name) {
                     rec_fget->args[i].name = PYBIND11_COMPAT_STRDUP(rec_fget->args[i].name);
diff --git a/tests/test_methods_and_attributes.cpp b/tests/test_methods_and_attributes.cpp
index e80780c..7fa7555 100644
--- a/tests/test_methods_and_attributes.cpp
+++ b/tests/test_methods_and_attributes.cpp
@@ -433,6 +433,13 @@
         .def_property("def_property_writeonly", nullptr, &TestProperties::set)
         .def_property_readonly("def_property_readonly", &TestProperties::get)
         .def_property("def_property", &TestProperties::get, &TestProperties::set)
+        .def_property(
+            "def_property_lambda",
+            [](const TestProperties &self) { return self.get(); },
+            [](TestProperties &self, int value) { return self.set(value); })
+        .def_property("def_property_cpp_function",
+                      py::cpp_function(&TestProperties::get),
+                      py::cpp_function(&TestProperties::set))
         .def_property("def_property_impossible", nullptr, nullptr)
         .def_readonly_static("def_readonly_static", &TestProperties::static_value)
         .def_readwrite_static("def_readwrite_static", &TestProperties::static_value)
diff --git a/tests/test_methods_and_attributes.py b/tests/test_methods_and_attributes.py
index 6ee4f5a..1d5d8ae 100644
--- a/tests/test_methods_and_attributes.py
+++ b/tests/test_methods_and_attributes.py
@@ -168,6 +168,80 @@
     assert NO_SETTER_MSG in str(excinfo.value)
 
 
+TestPropertiesGetDocstring = (
+    "(self: pybind11_tests.methods_and_attributes.TestProperties) -> int"
+)
+TestPropertiesSetDocstring = "(self: pybind11_tests.methods_and_attributes.TestProperties, arg0: typing.SupportsInt | typing.SupportsIndex) -> None"
+
+
+def test_properties_docstring():
+    assert (
+        m.TestProperties.def_readonly.fget.__doc__.strip() == TestPropertiesGetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_readwrite.fget.__doc__.strip()
+        == TestPropertiesGetDocstring
+    )
+    assert (
+        m.TestProperties.def_readwrite.fset.__doc__.strip()
+        == TestPropertiesSetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_writeonly.fset.__doc__.strip()
+        == TestPropertiesSetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_property_writeonly.fset.__doc__.strip()
+        == TestPropertiesSetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_property_readonly.fget.__doc__.strip()
+        == TestPropertiesGetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_property.fget.__doc__.strip() == TestPropertiesGetDocstring
+    )
+    assert (
+        m.TestProperties.def_property.fset.__doc__.strip() == TestPropertiesSetDocstring
+    )
+
+    assert (
+        m.TestProperties.def_property_lambda.fget.__doc__.strip()
+        == TestPropertiesGetDocstring
+    )
+    assert (
+        m.TestProperties.def_property_lambda.fset.__doc__.strip()
+        == TestPropertiesSetDocstring
+    )
+
+
+@pytest.mark.xfail(
+    reason="The signature of a cpp_function without is_method is fixed at construction",
+    strict=True,
+)
+def test_properties_docstring_cpp_function_getter():
+    assert (
+        m.TestProperties.def_property_cpp_function.fget.__doc__.strip()
+        == TestPropertiesGetDocstring
+    )
+
+
+@pytest.mark.xfail(
+    reason="The signature of a cpp_function without is_method is fixed at construction",
+    strict=True,
+)
+def test_properties_docstring_cpp_function_setter():
+    assert (
+        m.TestProperties.def_property_cpp_function.fset.__doc__.strip()
+        == TestPropertiesSetDocstring
+    )
+
+
 def test_static_properties():
     assert m.TestProperties.def_readonly_static == 1
     with pytest.raises(AttributeError) as excinfo: