Skip to content

Commit 48623e2

Browse files
authored
Merge pull request #76 from Distributive-Network/Xmader/fix/JSObjectProxy-as-dict
make JSObjectProxy function as a dict
2 parents c8565d5 + 0f06ca1 commit 48623e2

File tree

8 files changed

+134
-0
lines changed

8 files changed

+134
-0
lines changed

include/JSObjectProxy.hh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ public:
112112
* @return bool - Whether the compared objects are equal or not
113113
*/
114114
static bool JSObjectProxy_richcompare_helper(JSObjectProxy *self, PyObject *other, std::unordered_map<PyObject *, PyObject *> &visited);
115+
116+
/**
117+
* @brief Return an iterator object to make JSObjectProxy iterable, emitting (key, value) tuples
118+
*
119+
* @param self - The JSObjectProxy
120+
* @return PyObject* - iterator object
121+
*/
122+
static PyObject *JSObjectProxy_iter(JSObjectProxy *self);
123+
124+
/**
125+
* @brief Compute a string representation of the JSObjectProxy
126+
*
127+
* @param self - The JSObjectProxy
128+
* @return the string representation (a PyUnicodeObject) on success, NULL on failure
129+
*/
130+
static PyObject *JSObjectProxy_repr(JSObjectProxy *self);
115131
};
116132

117133

include/PyProxyHandler.hh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,9 @@ public:
181181
bool delete_(JSContext *cx, JS::HandleObject proxy, JS::HandleId id, JS::ObjectOpResult &result) const override;
182182
};
183183

184+
/**
185+
* @brief Convert jsid to a PyObject to be used as dict keys
186+
*/
187+
PyObject *idToKey(JSContext *cx, JS::HandleId id);
188+
184189
#endif

include/pyTypeFactory.hh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ PyType *pyTypeFactory(PyObject *object);
3636
* @return PyType* - Pointer to a PyType object corresponding to the JS::Value
3737
*/
3838
PyType *pyTypeFactory(JSContext *cx, JS::Rooted<JSObject *> *thisObj, JS::Rooted<JS::Value> *rval);
39+
/**
40+
* @brief same to pyTypeFactory, but it's guaranteed that no error would be set on the Python error stack, instead
41+
* return `pythonmonkey.null` on error
42+
*/
43+
PyType *pyTypeFactorySafe(JSContext *cx, JS::Rooted<JSObject *> *thisObj, JS::Rooted<JS::Value> *rval);
3944

4045
/**
4146
* @brief Helper function for pyTypeFactory to create FuncTypes through PyCFunction_New

python/pythonmonkey/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,18 @@
99
# Load the module by default to make `console`/`atob`/`btoa` globally available
1010
require("console")
1111
require("base64")
12+
13+
# Add the `.keys()` method on `Object.prototype` to get JSObjectProxy dict() conversion working
14+
# Conversion from a dict-subclass to a strict dict by `dict(subclass)` internally calls the .keys() method to read the dictionary keys,
15+
# but .keys on a JSObjectProxy can only come from the JS side
16+
pm.eval("""
17+
(makeList) => {
18+
const keysMethod = {
19+
get() {
20+
return () => makeList(...Object.keys(this))
21+
}
22+
}
23+
Object.defineProperty(Object.prototype, "keys", keysMethod)
24+
Object.defineProperty(Array.prototype, "keys", keysMethod)
25+
}
26+
""")(lambda *args: list(args))

src/JSObjectProxy.cc

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include "include/modules/pythonmonkey/pythonmonkey.hh"
1515
#include "include/jsTypeFactory.hh"
1616
#include "include/pyTypeFactory.hh"
17+
#include "include/PyProxyHandler.hh"
1718

1819
#include <jsapi.h>
1920
#include <jsfriendapi.h>
@@ -194,3 +195,58 @@ bool JSObjectProxyMethodDefinitions::JSObjectProxy_richcompare_helper(JSObjectPr
194195

195196
return true;
196197
}
198+
199+
PyObject *JSObjectProxyMethodDefinitions::JSObjectProxy_iter(JSObjectProxy *self) {
200+
JSContext *cx = GLOBAL_CX;
201+
JS::RootedObject *global = new JS::RootedObject(cx, JS::GetNonCCWObjectGlobal(self->jsObject));
202+
203+
// Get **enumerable** own properties
204+
JS::RootedIdVector props(cx);
205+
if (!js::GetPropertyKeys(cx, self->jsObject, JSITER_OWNONLY, &props)) {
206+
return NULL;
207+
}
208+
209+
// Populate a Python tuple with (propertyKey, value) pairs from the JS object
210+
// Similar to `Object.entries()`
211+
size_t length = props.length();
212+
PyObject *seq = PyTuple_New(length);
213+
for (size_t i = 0; i < length; i++) {
214+
JS::HandleId id = props[i];
215+
PyObject *key = idToKey(cx, id);
216+
217+
JS::RootedValue *jsVal = new JS::RootedValue(cx);
218+
JS_GetPropertyById(cx, self->jsObject, id, jsVal);
219+
PyObject *value = pyTypeFactory(cx, global, jsVal)->getPyObject();
220+
221+
PyTuple_SetItem(seq, i, PyTuple_Pack(2, key, value));
222+
}
223+
224+
// Convert to a Python iterator
225+
return PyObject_GetIter(seq);
226+
}
227+
228+
PyObject *JSObjectProxyMethodDefinitions::JSObjectProxy_repr(JSObjectProxy *self) {
229+
// Detect cyclic objects
230+
PyObject *objPtr = PyLong_FromVoidPtr(self->jsObject.get());
231+
// For `Py_ReprEnter`, we must get a same PyObject when visiting the same JSObject.
232+
// We cannot simply use the object returned by `PyLong_FromVoidPtr` because it won't reuse the PyLongObjects for ints not between -5 and 256.
233+
// Instead, we store this PyLongObject in a global dict, using itself as the hashable key, effectively interning the PyLongObject.
234+
PyObject *tsDict = PyThreadState_GetDict();
235+
PyObject *cyclicKey = PyDict_SetDefault(tsDict, /*key*/ objPtr, /*value*/ objPtr); // cyclicKey = (tsDict[objPtr] ??= objPtr)
236+
int status = Py_ReprEnter(cyclicKey);
237+
if (status != 0) { // the object has already been processed
238+
return status > 0 ? PyUnicode_FromString("[Circular]") : NULL;
239+
}
240+
241+
// Convert JSObjectProxy to a dict
242+
PyObject *dict = PyDict_New();
243+
// Update from the iterator emitting key-value pairs
244+
// see https://docs.python.org/3/c-api/dict.html#c.PyDict_MergeFromSeq2
245+
PyDict_MergeFromSeq2(dict, JSObjectProxy_iter(self), /*override*/ false);
246+
// Get the string representation of this dict
247+
PyObject *str = PyObject_Repr(dict);
248+
249+
Py_ReprLeave(cyclicKey);
250+
PyDict_DelItem(tsDict, cyclicKey);
251+
return str;
252+
}

src/modules/pythonmonkey/pythonmonkey.cc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ PyTypeObject JSObjectProxyType = {
7373
.tp_name = "pythonmonkey.JSObjectProxy",
7474
.tp_basicsize = sizeof(JSObjectProxy),
7575
.tp_dealloc = (destructor)JSObjectProxyMethodDefinitions::JSObjectProxy_dealloc,
76+
.tp_repr = (reprfunc)JSObjectProxyMethodDefinitions::JSObjectProxy_repr,
7677
.tp_as_mapping = &JSObjectProxy_mapping_methods,
7778
.tp_getattro = (getattrofunc)JSObjectProxyMethodDefinitions::JSObjectProxy_get,
7879
.tp_setattro = (setattrofunc)JSObjectProxyMethodDefinitions::JSObjectProxy_assign,
7980
.tp_flags = Py_TPFLAGS_DEFAULT
8081
| Py_TPFLAGS_DICT_SUBCLASS, // https://docs.python.org/3/c-api/typeobj.html#Py_TPFLAGS_DICT_SUBCLASS
8182
.tp_doc = PyDoc_STR("Javascript Object proxy dict"),
8283
.tp_richcompare = (richcmpfunc)JSObjectProxyMethodDefinitions::JSObjectProxy_richcompare,
84+
.tp_iter = (getiterfunc)JSObjectProxyMethodDefinitions::JSObjectProxy_iter,
8385
.tp_base = &PyDict_Type,
8486
.tp_init = (initproc)JSObjectProxyMethodDefinitions::JSObjectProxy_init,
8587
.tp_new = JSObjectProxyMethodDefinitions::JSObjectProxy_new,

src/pyTypeFactory.cc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ PyType *pyTypeFactory(JSContext *cx, JS::Rooted<JSObject *> *thisObj, JS::Rooted
175175
return NULL;
176176
}
177177

178+
PyType *pyTypeFactorySafe(JSContext *cx, JS::Rooted<JSObject *> *thisObj, JS::Rooted<JS::Value> *rval) {
179+
PyType *v = pyTypeFactory(cx, thisObj, rval);
180+
if (PyErr_Occurred()) {
181+
// Clear Python error
182+
PyErr_Clear();
183+
// Return `pythonmonkey.null` on error
184+
return new NullType();
185+
}
186+
return v;
187+
}
188+
178189
PyObject *callJSFunc(PyObject *jsCxThisFuncTuple, PyObject *args) {
179190
// TODO (Caleb Aikens) convert PyObject *args to JS::Rooted<JS::ValueArray> JSargs
180191
JSContext *cx = (JSContext *)PyLong_AsVoidPtr(PyTuple_GetItem(jsCxThisFuncTuple, 0));

tests/python/test_dicts_lists.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@ def test_eval_objects_proxy_proto():
7373
assert pm.null == pm.eval("(o) => Object.getPrototypeOf(o)")({})
7474
assert pm.null == pm.eval("(o) => Object.getPrototypeOf(o)")({ "abc": 1 })
7575

76+
def test_eval_objects_proxy_iterate():
77+
obj = pm.eval("({ a: 123, b: 'test' })")
78+
result = []
79+
for i in obj:
80+
result.append(i)
81+
assert result == [('a', 123.0), ('b', 'test')]
82+
83+
def test_eval_objects_proxy_repr():
84+
obj = pm.eval("({ a: 123, b: 'test' , c: { d: 1 }})")
85+
obj.e = obj # supporting circular references
86+
expected = "{'a': 123.0, 'b': 'test', 'c': {'d': 1.0}, 'e': [Circular]}"
87+
assert repr(obj) == expected
88+
assert str(obj) == expected
89+
90+
def test_eval_objects_proxy_dict_conversion():
91+
obj = pm.eval("({ a: 123, b: 'test' , c: { d: 1 }})")
92+
d = dict(obj)
93+
assert type(obj) is not dict # dict subclass
94+
assert type(d) is dict # strict dict
95+
assert repr(d) == "{'a': 123.0, 'b': 'test', 'c': {'d': 1.0}}"
96+
assert obj.keys() == ['a', 'b', 'c'] # Conversion from a dict-subclass to a strict dict internally calls the .keys() method
97+
assert list(d.keys()) == obj.keys()
98+
assert obj == d
99+
76100
def test_eval_objects_jsproxy_get():
77101
proxy = pm.eval("({a: 1})")
78102
assert 1.0 == proxy['a']

0 commit comments

Comments
 (0)