Merge pull request #437 from dean0x7d/dynamic-attrs
Add dynamic attribute support
diff --git a/docs/classes.rst b/docs/classes.rst
index 4270b8d..300816d 100644
--- a/docs/classes.rst
+++ b/docs/classes.rst
@@ -165,6 +165,66 @@
static variables and properties. Please also see the section on
:ref:`static_properties` in the advanced part of the documentation.
+Dynamic attributes
+==================
+
+Native Python classes can pick up new attributes dynamically:
+
+.. code-block:: pycon
+
+ >>> class Pet:
+ ... name = 'Molly'
+ ...
+ >>> p = Pet()
+ >>> p.name = 'Charly' # overwrite existing
+ >>> p.age = 2 # dynamically add a new attribute
+
+By default, classes exported from C++ do not support this and the only writable
+attributes are the ones explicitly defined using :func:`class_::def_readwrite`
+or :func:`class_::def_property`.
+
+.. code-block:: cpp
+
+ py::class_<Pet>(m, "Pet")
+ .def(py::init<>())
+ .def_readwrite("name", &Pet::name);
+
+Trying to set any other attribute results in an error:
+
+.. code-block:: pycon
+
+ >>> p = example.Pet()
+ >>> p.name = 'Charly' # OK, attribute defined in C++
+ >>> p.age = 2 # fail
+ AttributeError: 'Pet' object has no attribute 'age'
+
+To enable dynamic attributes for C++ classes, the :class:`py::dynamic_attr` tag
+must be added to the :class:`py::class_` constructor:
+
+.. code-block:: cpp
+
+ py::class_<Pet>(m, "Pet", py::dynamic_attr())
+ .def(py::init<>())
+ .def_readwrite("name", &Pet::name);
+
+Now everything works as expected:
+
+.. code-block:: pycon
+
+ >>> p = example.Pet()
+ >>> p.name = 'Charly' # OK, overwrite value in C++
+ >>> p.age = 2 # OK, dynamically add a new attribute
+ >>> p.__dict__ # just like a native Python class
+ {'age': 2}
+
+Note that there is a small runtime cost for a class with dynamic attributes.
+Not only because of the addition of a ``__dict__``, but also because of more
+expensive garbage collection tracking which must be activated to resolve
+possible circular references. Native Python classes incur this same cost by
+default, so this is not anything to worry about. By default, pybind11 classes
+are more efficient than native Python classes. Enabling dynamic attributes
+just brings them on par.
+
.. _inheritance:
Inheritance
diff --git a/include/pybind11/attr.h b/include/pybind11/attr.h
index f37e862..15bb2e4 100644
--- a/include/pybind11/attr.h
+++ b/include/pybind11/attr.h
@@ -44,6 +44,9 @@
/// Annotation indicating that a class is involved in a multiple inheritance relationship
struct multiple_inheritance { };
+/// Annotation which enables dynamic attributes, i.e. adds `__dict__` to a class
+struct dynamic_attr { };
+
NAMESPACE_BEGIN(detail)
/* Forward declarations */
enum op_id : int;
@@ -162,6 +165,9 @@
/// Multiple inheritance marker
bool multiple_inheritance = false;
+ /// Does the class manage a __dict__?
+ bool dynamic_attr = false;
+
PYBIND11_NOINLINE void add_base(const std::type_info *base, void *(*caster)(void *)) {
auto base_info = detail::get_type_info(*base, false);
if (!base_info) {
@@ -292,6 +298,11 @@
static void init(const multiple_inheritance &, type_record *r) { r->multiple_inheritance = true; }
};
+template <>
+struct process_attribute<dynamic_attr> : process_attribute_default<dynamic_attr> {
+ static void init(const dynamic_attr &, type_record *r) { r->dynamic_attr = true; }
+};
+
/***
* Process a keep_alive call policy -- invokes keep_alive_impl during the
* pre-call handler if both Nurse, Patient != 0 and use the post-call handler
diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h
index f7d46cf..f19435d 100644
--- a/include/pybind11/pybind11.h
+++ b/include/pybind11/pybind11.h
@@ -573,6 +573,33 @@
};
NAMESPACE_BEGIN(detail)
+extern "C" inline PyObject *get_dict(PyObject *op, void *) {
+ PyObject *&dict = *_PyObject_GetDictPtr(op);
+ if (!dict) {
+ dict = PyDict_New();
+ }
+ Py_XINCREF(dict);
+ return dict;
+}
+
+extern "C" inline int set_dict(PyObject *op, PyObject *new_dict, void *) {
+ if (!PyDict_Check(new_dict)) {
+ PyErr_Format(PyExc_TypeError, "__dict__ must be set to a dictionary, not a '%.200s'",
+ Py_TYPE(new_dict)->tp_name);
+ return -1;
+ }
+ PyObject *&dict = *_PyObject_GetDictPtr(op);
+ Py_INCREF(new_dict);
+ Py_CLEAR(dict);
+ dict = new_dict;
+ return 0;
+}
+
+static PyGetSetDef generic_getset[] = {
+ {const_cast<char*>("__dict__"), get_dict, set_dict, nullptr, nullptr},
+ {nullptr, nullptr, nullptr, nullptr, nullptr}
+};
+
/// Generic support for creating new Python heap types
class generic_type : public object {
template <typename...> friend class class_;
@@ -684,6 +711,16 @@
#endif
type->ht_type.tp_flags &= ~Py_TPFLAGS_HAVE_GC;
+ /* Support dynamic attributes */
+ if (rec->dynamic_attr) {
+ type->ht_type.tp_flags |= Py_TPFLAGS_HAVE_GC;
+ type->ht_type.tp_dictoffset = type->ht_type.tp_basicsize; // place the dict at the end
+ type->ht_type.tp_basicsize += sizeof(PyObject *); // and allocate enough space for it
+ type->ht_type.tp_getset = generic_getset;
+ type->ht_type.tp_traverse = traverse;
+ type->ht_type.tp_clear = clear;
+ }
+
type->ht_type.tp_doc = tp_doc;
if (PyType_Ready(&type->ht_type) < 0)
@@ -785,10 +822,27 @@
if (self->weakrefs)
PyObject_ClearWeakRefs((PyObject *) self);
+
+ PyObject **dict_ptr = _PyObject_GetDictPtr((PyObject *) self);
+ if (dict_ptr) {
+ Py_CLEAR(*dict_ptr);
+ }
}
Py_TYPE(self)->tp_free((PyObject*) self);
}
+ static int traverse(PyObject *op, visitproc visit, void *arg) {
+ PyObject *&dict = *_PyObject_GetDictPtr(op);
+ Py_VISIT(dict);
+ return 0;
+ }
+
+ static int clear(PyObject *op) {
+ PyObject *&dict = *_PyObject_GetDictPtr(op);
+ Py_CLEAR(dict);
+ return 0;
+ }
+
void install_buffer_funcs(
buffer_info *(*get_buffer)(PyObject *, void *),
void *get_buffer_data) {
diff --git a/tests/test_methods_and_attributes.cpp b/tests/test_methods_and_attributes.cpp
index 8b0351e..4948dc0 100644
--- a/tests/test_methods_and_attributes.cpp
+++ b/tests/test_methods_and_attributes.cpp
@@ -53,6 +53,12 @@
int value = 0;
};
+class DynamicClass {
+public:
+ DynamicClass() { print_default_created(this); }
+ ~DynamicClass() { print_destroyed(this); }
+};
+
test_initializer methods_and_attributes([](py::module &m) {
py::class_<ExampleMandA>(m, "ExampleMandA")
.def(py::init<>())
@@ -81,4 +87,7 @@
.def("__str__", &ExampleMandA::toString)
.def_readwrite("value", &ExampleMandA::value)
;
+
+ py::class_<DynamicClass>(m, "DynamicClass", py::dynamic_attr())
+ .def(py::init());
});
diff --git a/tests/test_methods_and_attributes.py b/tests/test_methods_and_attributes.py
index 9340e6f..04f2d12 100644
--- a/tests/test_methods_and_attributes.py
+++ b/tests/test_methods_and_attributes.py
@@ -1,3 +1,4 @@
+import pytest
from pybind11_tests import ExampleMandA, ConstructorStats
@@ -44,3 +45,67 @@
assert cstats.move_constructions >= 1
assert cstats.copy_assignments == 0
assert cstats.move_assignments == 0
+
+
+def test_dynamic_attributes():
+ from pybind11_tests import DynamicClass
+
+ instance = DynamicClass()
+ assert not hasattr(instance, "foo")
+ assert "foo" not in dir(instance)
+
+ # Dynamically add attribute
+ instance.foo = 42
+ assert hasattr(instance, "foo")
+ assert instance.foo == 42
+ assert "foo" in dir(instance)
+
+ # __dict__ should be accessible and replaceable
+ assert "foo" in instance.__dict__
+ instance.__dict__ = {"bar": True}
+ assert not hasattr(instance, "foo")
+ assert hasattr(instance, "bar")
+
+ with pytest.raises(TypeError) as excinfo:
+ instance.__dict__ = []
+ assert str(excinfo.value) == "__dict__ must be set to a dictionary, not a 'list'"
+
+ cstats = ConstructorStats.get(DynamicClass)
+ assert cstats.alive() == 1
+ del instance
+ assert cstats.alive() == 0
+
+ # Derived classes should work as well
+ class Derived(DynamicClass):
+ pass
+
+ derived = Derived()
+ derived.foobar = 100
+ assert derived.foobar == 100
+
+ assert cstats.alive() == 1
+ del derived
+ assert cstats.alive() == 0
+
+
+def test_cyclic_gc():
+ from pybind11_tests import DynamicClass
+
+ # One object references itself
+ instance = DynamicClass()
+ instance.circular_reference = instance
+
+ cstats = ConstructorStats.get(DynamicClass)
+ assert cstats.alive() == 1
+ del instance
+ assert cstats.alive() == 0
+
+ # Two object reference each other
+ i1 = DynamicClass()
+ i2 = DynamicClass()
+ i1.cycle = i2
+ i2.cycle = i1
+
+ assert cstats.alive() == 2
+ del i1, i2
+ assert cstats.alive() == 0
diff --git a/tests/test_pickling.cpp b/tests/test_pickling.cpp
index 4494c24..3941dc5 100644
--- a/tests/test_pickling.cpp
+++ b/tests/test_pickling.cpp
@@ -24,6 +24,14 @@
int m_extra2 = 0;
};
+class PickleableWithDict {
+public:
+ PickleableWithDict(const std::string &value) : value(value) { }
+
+ std::string value;
+ int extra;
+};
+
test_initializer pickling([](py::module &m) {
py::class_<Pickleable>(m, "Pickleable")
.def(py::init<std::string>())
@@ -48,4 +56,26 @@
p.setExtra1(t[1].cast<int>());
p.setExtra2(t[2].cast<int>());
});
+
+ py::class_<PickleableWithDict>(m, "PickleableWithDict", py::dynamic_attr())
+ .def(py::init<std::string>())
+ .def_readwrite("value", &PickleableWithDict::value)
+ .def_readwrite("extra", &PickleableWithDict::extra)
+ .def("__getstate__", [](py::object self) {
+ /* Also include __dict__ in state */
+ return py::make_tuple(self.attr("value"), self.attr("extra"), self.attr("__dict__"));
+ })
+ .def("__setstate__", [](py::object self, py::tuple t) {
+ if (t.size() != 3)
+ throw std::runtime_error("Invalid state!");
+ /* Cast and construct */
+ auto& p = self.cast<PickleableWithDict&>();
+ new (&p) Pickleable(t[0].cast<std::string>());
+
+ /* Assign C++ state */
+ p.extra = t[1].cast<int>();
+
+ /* Assign Python state */
+ self.attr("__dict__") = t[2];
+ });
});
diff --git a/tests/test_pickling.py b/tests/test_pickling.py
index f6e4c04..5e62e1f 100644
--- a/tests/test_pickling.py
+++ b/tests/test_pickling.py
@@ -3,10 +3,10 @@
except ImportError:
import pickle
-from pybind11_tests import Pickleable
-
def test_roundtrip():
+ from pybind11_tests import Pickleable
+
p = Pickleable("test_value")
p.setExtra1(15)
p.setExtra2(48)
@@ -16,3 +16,17 @@
assert p2.value() == p.value()
assert p2.extra1() == p.extra1()
assert p2.extra2() == p.extra2()
+
+
+def test_roundtrip_with_dict():
+ from pybind11_tests import PickleableWithDict
+
+ p = PickleableWithDict("test_value")
+ p.extra = 15
+ p.dynamic = "Attribute"
+
+ data = pickle.dumps(p, pickle.HIGHEST_PROTOCOL)
+ p2 = pickle.loads(data)
+ assert p2.value == p.value
+ assert p2.extra == p.extra
+ assert p2.dynamic == p.dynamic