diff --git a/peps/pep-0818.rst b/peps/pep-0818.rst index e7dfd3ecd1a..189210d1381 100644 --- a/peps/pep-0818.rst +++ b/peps/pep-0818.rst @@ -853,69 +853,12 @@ following steps: The value ``jsthis`` is used to determine the value of ``this`` when calling a function. If ``jsobj`` is not callable, is has no effect. -Here is pseudocode for the functions ``create_jsproxy`` and -``create_jsproxy_with_flags``: - -.. code-block:: python - - def create_jsproxy(jsobj, jsthis=Js_undefined): - # For the definition of ``compute_type_flags``, see "Determining which flags to set". - return create_jsproxy_with_flags(compute_type_flags(jsobj), jsobj, jsthis) - - def create_jsproxy_with_flags(type_flags, jsobj, jsthis): - cls = get_jsproxy_class(type_flags) - return cls.__new__(jsobj, jsthis) - -The most important logic is for creating the classes, which works approximately -as follows: - -.. code-block:: python - - @functools.cache - def get_jsproxy_class(type_flags): - flag_mixin_pairs = [ - (HAS_GET, JSProxyHasGetMixin), - (HAS_HAS, JSProxyHasHasMixin), - # ... - (IS_PY_JSON_DICT, JSPyJsonDictMixin) - ] - bases = [mixin for flag, mixin in flag_mixin_pairs if flag & type_flags] - bases.insert(0, JSProxy) - if type_flags & IS_ERROR: - # We want JSException to be pickleable so it needs a distinct name - name = "jstypes.ffi.JSException" - bases.append(Exception) - else: - name = "jstypes.ffi.JSProxy" - ns = {"_js_type_flags": type_flags} - # Note: The actual way that we build the class does not result in the - # mixins appearing as entries on the mro. - return JSProxyMeta.__new__(JSProxyMeta, name, tuple(bases), ns) - - The ``JSProxy`` Metaclass ~~~~~~~~~~~~~~~~~~~~~~~~~ This metaclass overrides subclass checks so that if one ``JSProxy`` class has a superset of the flags of another ``JSProxy`` class, we report it as a subclass. -.. code:: python - - class _JSProxyMetaClass(type): - def __instancecheck__(cls, instance): - return cls.__subclasscheck__(type(instance)) - - def __subclasscheck__(cls, subcls): - if type.__subclasscheck__(cls, subcls): - return True - if not hasattr(subclass, "_js_type_flags"): - return False - - subcls_flags = subcls._js_type_flags - # Check whether the flags on subcls are a subset of the flags on cls - return cls._js_type_flags & subcls_flags == subcls_flags - - The ``JSProxy`` Base Class ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -998,788 +941,6 @@ we detect empty containers and return ``false``. } -The following helper function is used to implement ``__dir__``. It walks the -prototype chain and accumulates all keys, filtering out keys that start with -numbers (not valid Python identifiers) and reversing the -``normalize_python_keywords`` transform. We also filter out the -``Array.keys`` method. - -.. code:: javascript - - function js_dir(jsobj) { - let result = []; - let orig = jsobj; - do { - let keys = Object.getOwnPropertyNames(jsobj); - result.push(...keys); - } while ((jsobj = Object.getPrototypeOf(jsobj))); - // Filter out numbers - result = result.filter((s) => { - let c = s.charCodeAt(0); - return c < 48 || c > 57; - }); - - // Filter out "keys" key from an array - if (Array.isArray(orig)) { - result = result.filter((s) => { - return s !== "keys"; - }); - } - - // If the key is a keyword followed by 0 or more underscores, - // add an extra underscore to reverse the transformation applied by - // normalize_python_keywords(). - result = result.map((word) => - iskeyword(word.replace(/_*$/, "")) ? word + "_" : word, - ); - - return result; - }; - - -.. code:: python - - class JSProxy: - def __getattribute__(self, attr): - try: - return object.__getattribute__(self, attr) - except AttributeError: - pass - if attr == "keys" and Array.isArray(self): - raise AttributeError(attr) - - attr = normalize_python_keywords(attr) - js_getattr = run_js( - """ - (jsobj, attr) => jsobj[attr] - """ - ) - js_hasattr = run_js( - """ - (jsobj, attr) => attr in jsobj - """ - ) - result = js_getattr(self, attr) - if isjsfunction(result): - result = result.__get__(self) - if result is None and not js_hasattr(self, attr): - raise AttributeError(attr) - return result - - def __setattr__(self, attr, value): - if attr in ["__loader__", "__name__", "__package__", "__path__", "__spec__"]: - return object.__setattr__(self, attr, value) - attr = normalize_python_keywords(attr) - js_setattr = run_js( - """ - (jsobj, attr) => { - jsobj[attr] = value; - } - """ - ) - js_setattr(self, attr, value) - - def __delattr__(self, attr): - if attr in ["__loader__", "__name__", "__package__", "__path__", "__spec__"]: - return object.__delattr__(self, attr) - attr = normalize_python_keywords(attr) - js_delattr = run_js( - """ - (jsobj, attr) => { - delete jsobj[attr]; - } - """ - ) - js_delattr(self, attr) - - def __dir__(self): - return object.__dir__(self) + js_dir(self) - - def __eq__(self, other): - if not isinstance(other, JSProxy): - return False - js_eq = run_js("(x, y) => x === y") - return js_eq(self, other) - - def __ne__(self, other): - if not isinstance(other, JSProxy): - return True - js_neq = run_js("(x, y) => x !== y") - return js_neq(self, other) - - def __repr__(self): - js_repr = run_js("x => x.toString()") - return js_repr(self) - - def __bool__(self): - return js_bool(self) - - @property - def js_id(self): - """ - This returns an integer with the property that jsproxy1 == jsproxy2 - if and only if jsproxy1.js_id == jsproxy2.js_id. There is no way to - express the implementation in pseudocode. - """ - raise NotImplementedError - - def as_py_json(self): - """ - This is actually a mixin method. We leave it out if any of the flags - IS_CALLABLE, IS_DOUBLE_PROXY, IS_ERROR, or IS_ITERATOR - is set. - """ - flags = self._js_type_flags - if (flags & (IS_ARRAY | IS_ARRAY_LIKE)): - flags |= IS_PY_JSON_SEQUENCE - else: - flags |= IS_PY_JSON_DICT - return create_jsproxy_with_flags(flags, self, self.jsthis) - - def to_py(self, *, depth=-1, default_converter=None): - """ - See section on deep conversions. - """ - ... - - def object_entries(self): - js_object_entries = run_js("x => Object.entries(x)") - return js_object_entries(self) - - def object_keys(self): - js_object_keys = run_js("x => Object.keys(x)") - return js_object_keys(self) - - def object_values(self): - js_object_values = run_js("x => Object.values(x)") - return js_object_values(self) - - def to_weakref(self): - js_weakref = run_js("x => new WeakRef(x)") - return js_weakref(self) - -We need the following function which calls the ``as_py_json()`` method on -``value`` if it is present: - -.. code-block:: python - - def maybe_as_py_json(value): - if ( - isinstance(value, JSProxy) - and hasattr(value, as_py_json) - ): - return value.as_py_json() - return value - - -Determining Which Flags to Set -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -We need the helper function ``getTypeTag``: - -.. code:: javascript - - function getTypeTag(x) { - try { - return Object.prototype.toString.call(x); - } catch (e) { - // Catch and ignore errors - return ""; - } - } - -We use the following function to determine which flags to set: - -.. code:: javascript - - function compute_type_flags(obj, is_py_json) { - let type_flags = 0; - - const typeTag = getTypeTag(obj); - const hasLength = - isArray || (hasProperty(obj, "length") && typeof obj !== "function"); - - SET_FLAG_IF_HAS_METHOD(HAS_GET, "get"); - SET_FLAG_IF_HAS_METHOD(HAS_SET, "set"); - SET_FLAG_IF_HAS_METHOD(HAS_HAS, "has"); - SET_FLAG_IF_HAS_METHOD(HAS_INCLUDES, "includes"); - SET_FLAG_IF( - HAS_LENGTH, - hasProperty(obj, "size") || hasLength - ); - SET_FLAG_IF_HAS_METHOD(HAS_DISPOSE, Symbol.dispose); - SET_FLAG_IF(IS_CALLABLE, typeof obj === "function"); - SET_FLAG_IF(IS_ARRAY, Array.isArray(obj)); - SET_FLAG_IF( - IS_ARRAY_LIKE, - !isArray && hasLength && (type_flags & IS_ITERABLE)); - SET_FLAG_IF(IS_DOUBLE_PROXY, isPyProxy(obj)); - SET_FLAG_IF(IS_GENERATOR, typeTag === "[object Generator]"); - SET_FLAG_IF_HAS_METHOD(IS_ITERABLE, Symbol.iterator); - SET_FLAG_IF( - IS_ERROR, - hasProperty(obj, "name") && - hasProperty(obj, "message") && - (hasProperty(obj, "stack") || constructorName === "DOMException") && - !(type_flags & IS_CALLABLE) - ); - - if (is_py_json && type_flags & (IS_ARRAY | IS_ARRAY_LIKE)) { - type_flags |= IS_PY_JSON_SEQUENCE; - } else if ( - is_py_json && - !(type_flags & (IS_DOUBLE_PROXY | IS_ITERATOR | IS_CALLABLE | IS_ERROR)) - ) { - type_flags |= IS_PY_JSON_DICT; - } - const mapping_flags = HAS_GET | HAS_LENGTH | IS_ITERABLE; - const mutable_mapping_flags = mapping_flags | HAS_SET; - SET_FLAG_IF(IS_MAPPING, type_flags & (mapping_flags === mapping_flags)); - SET_FLAG_IF( - IS_MUTABLE_MAPPING, - type_flags & (mutable_mapping_flags === mutable_mapping_flags), - ); - - SET_FLAG_IF(IS_MAPPING, type_flags & IS_PY_JSON_DICT); - SET_FLAG_IF(IS_MUTABLE_MAPPING, type_flags & IS_PY_JSON_DICT); - - return type_flags; - } - -The ``HAS_GET`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -If a JavaScript ``get()`` method is present, we define ``__getitem__`` as -follows. If a ``has()`` method is also present, we'll use it to decide whether -an ``undefined`` return value should be treated as a key error or as ``None``. -If no ``has()`` method is present, ``undefined`` is treated as ``None``. - -.. code-block:: javascript - - function js_get(jsobj, item) { - const result = jsobj.get(item); - if (result !== undefined) { - return result; - } - if (hasMethod(obj, "has") && !obj.has(key)) { - throw new PythonKeyError(item); - } - return undefined; - } - -.. code-block:: python - - class JSProxyHasGetMixin: - def __getitem__(self, item): - result = js_get(self, item) - if self._js_type_flags & IS_PY_JSON_DICT: - result = maybe_as_py_json(result) - return result - -The ``HAS_SET`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -If a ``set()`` method is present, we assume a ``delete()`` method is also -present and define ``__setitem__`` and ``__delitem__`` as follows: - -.. code-block:: python - - class JSProxyHasSetMixin: - def __setitem__(self, item, value): - js_set = run_js( - """ - (jsobj, item, value) => { - jsobj.set(item, value); - } - """ - ) - js_set(self, item, value) - - def __delitem__(self, item, value): - js_delete = run_js( - """ - (jsobj, item) => { - jsobj.delete(item); - } - """ - ) - js_delete(self, item) - -The ``HAS_HAS`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - class JSProxyHasHasMixin: - def __contains__(self, item): - js_has = run_js( - """ - (jsobj, item) => jsobj.has(item); - """ - ) - return js_has(self, item) - - -The ``HAS_INCLUDES`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - class JSProxyHasIncludesMixin: - def __contains__(self, item): - js_includes = run_js( - """ - (jsobj, item) => jsobj.includes(item); - """ - ) - return js_includes(self, item) - - -The ``HAS_LENGTH`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~ - -We prefer to use the ``size`` attribute if present and a number and if not fall -back to returning the ``length``. If a JavaScript error is raised when looking -up either field, we allow it to propagate into Python as a -``JavaScriptException``. - -.. code-block:: python - - class JSProxyHasLengthMixin: - def __len__(self, item): - js_len = run_js( - """ - (jsobj) => { - const size = val.size; - if (typeof size === "number") { - return size; - } - return val.length - } - """ - ) - result = js_len(self) - if not isinstance(result, int): - raise TypeError("object does not have a valid length") - if result < 0: - raise ValueError("length of object is negative") - return result - -The ``HAS_DISPOSE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -This makes the ``JSProxy`` into a context manager where ``__enter__`` is a no-op -and ``__exit__`` calls the ``[Symbol.dispose]()`` method. - -.. code-block:: python - - class JSProxyContextManagerMixin: - def __enter__(self): - return self - - def __exit__(self, type, value, traceback): - js_symbol_dispose = run_js( - """ - (jsobj) => jsobj[Symbol.dispose]() - """ - ) - js_symbol_dispose(self) - - -The ``IS_ARRAY`` Mixin -~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - function js_array_slice(jsobj, length, start, stop, step) { - let result; - if (step === 1) { - result = obj.slice(start, stop); - } else { - result = Array.from({ length }, (_, i) => obj[start + i * step]); - } - return result; - } - - // we also use this for deletion by setting values to None - function js_array_slice_assign(obj, slicelength, start, stop, step, values) { - if (step === 1) { - obj.splice(start, slicelength, ...(values ?? [])); - return; - } - if (values !== undefined) { - for (let i = 0; i < slicelength; i++) { - obj.splice(start + i * step, 1, values[i]); - } - } - for (let i = slicelength - 1; i >= 0; i --) { - obj.splice(start + i * step, 1); - } - } - -.. code-block:: python - - class JSArrayMixin(MutableSequence, JSProxyHasLengthMixin): - def __getitem__(self, index): - if not isinstance(index, (int, slice)): - raise TypeError("Expected index to be an int or a slice") - length = len(self) - js_array_get = run_js( - """ - (jsobj, index) => jsobj[index] - """ - ) - if isinstance(index, int): - if index >= length: - raise IndexError(index) - if index < -length: - raise IndexError(index) - if index < 0: - index += length - result = js_array_get(self, index) - if self._js_type_flags & IS_PY_JSON_SEQUENCE: - result = maybe_as_py_json(result) - return result - start = index.start - stop = index.stop - step = index.step - slicelength = PySlice_AdjustIndices(length, &start, &stop, &step) - if (slicelength <= 0) { - return _PyJsvArray_New(); - } - result = js_array_slice(self, slicelength, start, stop, step) - if self._js_type_flags & IS_PY_JSON_SEQUENCE: - result = result.as_py_json() - return result - - - def __setitem__(self, index, value): - if not isinstance(index, (int, slice)): - raise TypeError("Expected index to be an int or a slice") - length = len(self) - js_array_set = run_js( - """ - (jsobj, index, value) => { jsobj[index] = value; } - """ - ) - if isinstance(index, int): - if index >= length: - raise IndexError(index) - if index < -length: - raise IndexError(index) - if index < 0: - index += length - result = js_array_set(self, index, value) - return - if not isinstance(value, Iterable): - raise TypeError("must assign iterable to extended slice") - seq = list(value) - start = index.start - stop = index.stop - step = index.step - slicelength = PySlice_AdjustIndices(length, &start, &stop, &step) - if step != 1 and len(seq) != slicelength: - raise TypeError( - f"attempted to assign sequence of length {len(seq)} to" - f"extended slice of length {slicelength}" - ) - if step != 1 and slicelength == 0: - return - js_array_slice_assign(self, slicelength, start, stop, step, seq) - - def __delitem__(self, index): - if not isinstance(index, (int, slice)): - raise TypeError("Expected index to be an int or a slice") - length = len(self) - js_array_delete = run_js( - """ - (jsobj, index) => { jsobj.splice(index, 1); } - """ - ) - if isinstance(index, int): - if index >= length: - raise IndexError(index) - if index < -length: - raise IndexError(index) - if index < 0: - index += length - result = js_array_delete(self, index) - return - start = index.start - stop = index.stop - step = index.step - slicelength = PySlice_AdjustIndices(length, &start, &stop, &step) - if step != 1 and slicelength == 0: - return - js_array_slice_assign(self, slicelength, start, stop, step, None) - - def insert(self, pos, value): - if not isinstance(pos, int): - raise TypeError("Expected an integer") - js_insert = run_js( - """ - (jsarr, pos, value) => { jsarr.splice(pos, value); } - """ - ) - js_insert(self, pos, value) - -The ``IS_ARRAY_LIKE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - -.. code-block:: python - - class JSArrayLikeMixin(MutableSequence, JSProxyHasLengthMixin): - def __getitem__(self, index): - if not isinstance(index, int): - raise TypeError("Expected index to be an int") - JSArrayMixin.__getitem__(self, index) - - def __setitem__(self, index, value): - if not isinstance(index, int): - raise TypeError("Expected index to be an int") - JSArrayMixin.__setitem__(self, index, value) - - def __delitem__(self, index): - if not isinstance(index, int): - raise TypeError("Expected index to be an int") - JSArrayMixin.__delitem__(self, index, value) - -The ``IS_CALLABLE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ -We already gave more accurate C code for calling a ``JSCallable``. See in -particular the definition of ``JSMethod_ConvertArgs()`` given there. - -.. code-block:: python - - class JSCallableMixin: - def __get__(self, obj): - """Return a new jsproxy bound to jsthis with the same JS object""" - return create_jsproxy(self, jsthis=obj) - - def __call__(self, *args, **kwargs): - """See the description of JSMethod_Vectorcall""" - - def new(self, *args, **kwargs): - pyproxies = [] - jsargs = JSMethod_ConvertArgs(args, kwargs, pyproxies) - - do_construct = run_js( - """ - (jsfunc, jsargs) => - Reflect.construct(jsfunc, jsargs) - """ - ) - result = do_construct(self, jsargs) - msg = ( - "This borrowed proxy was automatically destroyed " - "at the end of a function call." - ) - for px in pyproxies: - px.destroy(msg) - return result - -The ``IS_ERROR`` Mixin -~~~~~~~~~~~~~~~~~~~~~~ - -In this case, we inherit from both ``Exception`` and ``JSProxy``. We also make -sure that the resulting class is pickleable. - -The ``IS_ITERABLE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -If the iterable has the ``IS_PY_JSON_DICT`` flag set, we iterate over the object -keys. Otherwise, call ``obj[Symbol.iterator]()``. If either -``IS_PY_JSON_SEQUENCE`` or ``IS_PY_JSON_DICT``, we call ``maybe_as_py_json`` on -the iteration results. - -.. code-block:: python - - def wrap_with_maybe_as_py_json(it): - try: - while val := it.next() - yield maybe_as_py_json(val) - except StopIteration(result): - return maybe_as_py_json(result) - - - class JSIterableMixin: - def __iter__(self): - pyjson = self._js_type_flags & (IS_PY_JSON_SEQUENCE | IS_PY_JSON_DICT) - pyjson_dict = self._js_type_flags & IS_PY_JSON_DICT - js_get_iter = run_js( - """ - (obj) => obj[Symbol.iterator]() - """ - ) - - if pyjson_dict: - result = iter(self.object_keys()) - else: - result = js_get_iter(self) - - if pyjson: - result = wrap_with_maybe_as_py_json(result) - return result - - -The ``IS_ITERATOR`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The JavaScript ``next`` method returns an ``IteratorResult`` which has a -``done`` field and a ``value`` field. If ``done`` is ``true``, we have to raise -a ``StopIteration`` exception to convert to the Python iterator protocol. - -.. code-block:: python - - class JSIteratorMixin: - def __iter__(self): - return self - - def send(self, arg): - js_next = run_js( - """ - (obj, arg) => obj.next(arg) - """ - ) - it_result = js_next(self, arg) - value = it_result.value - if it_result.done: - raise StopIteration(value) - return value - - def __next__(self): - return self.send(None) - - -The ``IS_GENERATOR`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Python generators have a ``close()`` method which takes no arguments instead of -a ``return()`` method. We also have to translate ``gen.throw(GeneratorExit)`` -into ``jsgen.return_()``. It is possible to call ``jsgen.return_(val)`` directly -if there is a need to return a specific value. - -.. code-block:: python - - class JSGeneratorMixin(JSIteratorMixin): - def throw(self, exc): - if isinstance(exc, GeneratorExit): - js_throw = run_js( - """ - (obj, exc) => obj.return() - """ - ) - else: - js_throw = run_js( - """ - (obj, exc) => obj.throw(exc) - """ - ) - it_result = js_throw(self, exc) - # if the error wasn't caught it will get raised back out. - # now handle the case where the error got caught. - value = it_result.value - if self._js_type_flags & IS_PY_JSON_SEQUENCE: - value = maybe_as_py_json(value) - if it_result.done: - raise StopIteration(value) - return value - - def close(self): - self.throw(GeneratorExit) - -The ``IS_MAPPING`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~ - -If the ``IS_MAPPING`` flag is set, we implement all of the ``Mapping`` methods. -We only set this flag when there are enough other flags set that the abstract -``Mapping`` methods are defined. We use the default implementations for all the -mixin methods. - -The ``IS_MUTABLE_MAPPING`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If the ``IS_MUTABLE_MAPPING`` flag is set, we implement all of the -``MutableMapping`` methods. We only set this flag when there are enough other -flags set that the abstract ``MutableMapping`` methods are defined. We use the -default implementations for all the mixin methods. - - -The ``IS_PY_JSON_SEQUENCE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This flag only ever appears with ``IS_ARRAY``. It changes the behavior of -``JSArray.__getitem__`` to apply ``maybe_as_py_json()`` to the result. - -The ``IS_PY_JSON_DICT`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - class JSPyJsonDictMixin(MutableMapping): - def __getitem__(self, key): - if not isinstance(key, str): - raise KeyError(key) - js_get = run_js( - """ - (jsobj, key) => jsobj[key] - """ - ) - result = js_get(self, key) - if result is None and not key in self: - raise KeyError(key) - return maybe_as_py_json(result) - - def __setitem__(self, key, value): - if not isinstance(key, str): - raise TypeError("only keys of type string are supported") - js_set = run_js( - """ - (jsobj, key, value) => { - jsobj[key] = value; - } - """ - ) - js_set(self, key, value) - - def __delitem__(self, key): - if not isinstance(key, str): - raise TypeError("only keys of type string are supported") - if not key in self: - raise KeyError(key) - js_delete = run_js( - """ - (jsobj, key) => { - delete jsobj[key]; - } - """ - ) - js_delete(self, key) - - def __contains__(self, key): - if not isinstance(key, str): - return False - js_contains = run_js( - """ - (jsobj, key) => key in jsobj - """ - ) - return js_contains(self, key) - - def __len__(self): - return sum(1 for _ in self) - - def __iter__(self): - # defined by IS_ITERABLE mixin, see implementation there. - - -The ``IS_DOUBLE_PROXY`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -In this case the object is a ``JSProxy`` of a ``PyProxy``. We add an extra -``unwrap()`` method that returns the inner Python object. - PyProxy ------- @@ -1862,1030 +1023,34 @@ and mark it as destroyed. As a result, if we attempt to do anything with the Creating a ``PyProxy`` ~~~~~~~~~~~~~~~~~~~~~~ -Given a collection of type flags, we use the following function to generate the -``PyProxy`` class: - -.. code-block:: javascript - - let pyproxyClassMap = new Map(); - function getPyProxyClass(flags: number) { - let result = pyproxyClassMap.get(flags); - if (result) { - return result; - } - let descriptors: any = {}; - const FLAG_MIXIN_PAIRS: [number, any][] = [ - [HAS_CONTAINS, PyContainsMixin], - // ... other flag mixin pairs - [IS_MUTABLE_SEQUENCE, PyMutableSequenceMixin], - ]; - for (let [feature_flag, methods] of FLAG_MIXIN_PAIRS) { - if (flags & feature_flag) { - Object.assign( - descriptors, - Object.getOwnPropertyDescriptors(methods.prototype), - ); - } - } - // Use base constructor (just throws an error if construction is attempted). - descriptors.constructor = Object.getOwnPropertyDescriptor( - PyProxyProto, - "constructor", - ); - // $$flags static field - Object.assign( - descriptors, - Object.getOwnPropertyDescriptors({ $$flags: flags }), - ); - // We either inherit PyProxyFunction as the base class if we're callable or - // from PyProxy if we're not. - const superProto = flags & IS_CALLABLE ? PyProxyFunctionProto : PyProxyProto; - const subProto = Object.create(superProto, descriptors); - function NewPyProxyClass() {} - NewPyProxyClass.prototype = subProto; - pyproxyClassMap.set(flags, NewPyProxyClass); - return NewPyProxyClass; - } - -To create a ``PyProxy`` we also need to be able to get the appropriate handlers: - -.. code-block:: javascript - - function getPyProxyHandlers(flags) { - if (flags & IS_JS_JSON_DICT) { - return PyProxyJsJsonDictHandlers; - } - if (flags & IS_DICT) { - return PyProxyDictHandlers; - } - if (flags & IS_SEQUENCE) { - return PyProxySequenceHandlers; - } - return PyProxyHandlers; - } - -We use the following function to create the target object for the ES6 proxy: - -.. code-block:: javascript - - function createTarget(flags) { - const pyproxyClass = getPyProxyClass(flags); - if (!(flags & IS_CALLABLE)) { - return Object.create(cls.prototype); - } - // In this case we are effectively subclassing Function in order to ensure - // that the proxy is callable. With a Content Security Protocol that doesn't - // allow unsafe-eval, we can't invoke the Function constructor directly. So - // instead we create a function in the universally allowed way and then use - // `setPrototypeOf`. The documentation for `setPrototypeOf` says to use - // `Object.create` or `Reflect.construct` instead for performance reasons - // but neither of those work here. - const target = function () {}; - Object.setPrototypeOf(target, cls.prototype); - // Remove undesirable properties added by Function constructor. Note: we - // can't remove "arguments" or "caller" because they are not configurable - // and not writable - delete target.length; - delete target.name; - // prototype isn't configurable so we can't delete it but it is writable. - target.prototype = undefined; - return target; - } - -``createPyProxy`` takes the following options: - -flags - If this is passed, we use the passed flags rather than feature - detecting the object again. - -props - Information that not shared with other PyProxies of the same lifetime. - -shared - Data that is shared between all proxies with the same lifetime as this one. +To create a ``PyProxy`` from a Python object we do the following steps: -gcRegister - Should we register this with the JavaScript garbage collector? +1. calculate the appropriate type flags for the Python object +2. get or create an appropriate ``PyProxy`` class with the mixins appropriate + for the type flags that are set +3. get or create an appropriate set of `ES6 Proxy handlers + `_ + for the object, +4. instantiate the class for the particular python object, +5. wrap the resulting object in a proxy using the proxy handlers. -.. code-block:: javascript - - const pyproxyAttrsSymbol = Symbol("pyproxy.attrs"); - function createPyProxy( - pyObjectPtr: number, - { - flags, - props, - shared, - gcRegister, - } - ) { - if (gcRegister === undefined) { - // register by default - gcRegister = true; - } - - // See the section "Determining which flags to set" for the definition of - // get_pyproxy_flags - const pythonGetFlags = makePythonFunction("get_pyproxy_flags"); - flags ??= pythonGetFlags(pyObjectPtr); - const target = createTarget(flags); - const handlers = getPyProxyHandlers(flags); - const proxy = new Proxy(target, handlers); - - props = Object.assign( - { isBound: false, captureThis: false, boundArgs: [], roundtrip: false }, - props, - ); - - // If shared was passed the new PyProxy will have a shared lifetime - // with some other PyProxy. - // This happens in asJsJson(), bind(), and captureThis(). - // It specifically does not happen in copy() - if (!shared) { - shared = { - pyObjectPtr, - destroyed_msg: undefined, - gcRegistered: false, - }; - _Py_IncRef(pyObjectPtr); - if (gcRegister) { - gcRegisterPyProxy(shared); - } - } - target[pyproxyAttrsSymbol] = { shared, props }; - return proxy; - } - The ``PyProxy`` Base Class ~~~~~~~~~~~~~~~~~~~~~~~~~~ -The default handlers are as follows: - -.. code-block:: javascript - - function filteredHasKey(jsobj, jskey, filterProto) { - let result = jskey in jsobj; - if (jsobj instanceof Function) { - // If we are a PyProxy of a callable we have to subclass function so that if - // someone feature detects callables with `instanceof Function` it works - // correctly. But the callable might have attributes `name` and `length` and - // we don't want to shadow them with the values from `Function.prototype`. - result &&= !( - ["name", "length", "caller", "arguments"].includes(jskey) || - // we are required by JS law to return `true` for `"prototype" in pycallable` - // but we are allowed to return the value of `getattr(pycallable, "prototype")`. - // So we filter prototype out of the "get" trap but not out of the "has" trap - (filterProto && jskey === "prototype") - ); - } - return result; - } - - const PyProxyHandlers = { - isExtensible() { - return true; - }, - has(jsobj, jskey) { - // Must report "prototype" in proxy when we are callable. - // (We can return the wrong value from "get" handler though.) - if (filteredHasKey(jsobj, jskey, false)) { - return true; - } - // hasattr will crash if given a Symbol. - if (typeof jskey === "symbol") { - return false; - } - if (jskey.startsWith("$")) { - jskey = jskey.slice(1); - } - const pythonHasAttr = makePythonFunction("hasattr"); - return pythonHasAttr(jsobj, jskey); - }, - get(jsobj, jskey) { - // Preference order: - // 1. stuff from JavaScript - // 2. the result of Python getattr - // pythonGetAttr will crash if given a Symbol. - if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) { - return Reflect.get(jsobj, jskey); - } - if (jskey.startsWith("$")) { - jskey = jskey.slice(1); - } - // 2. The result of getattr - const pythonGetAttr = makePythonFunction("getattr"); - return pythonGetAttr(jsobj, jskey); - }, - set(jsobj, jskey, jsval) { - let descr = Object.getOwnPropertyDescriptor(jsobj, jskey); - if (descr && !descr.writable && !descr.set) { - return false; - } - // pythonSetAttr will crash if given a Symbol. - if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) { - return Reflect.set(jsobj, jskey, jsval); - } - if (jskey.startsWith("$")) { - jskey = jskey.slice(1); - } - const pythonSetAttr = makePythonFunction("setattr"); - pythonSetAttr(jsobj, jskey, jsval); - return true; - }, - deleteProperty(jsobj, jskey: string | symbol): boolean { - let descr = Object.getOwnPropertyDescriptor(jsobj, jskey); - if (descr && !descr.configurable) { - // Must return "false" if "jskey" is a nonconfigurable own property. - // Strict mode JS will throw an error here saying that the property cannot - // be deleted. - return false; - } - if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) { - return Reflect.deleteProperty(jsobj, jskey); - } - if (jskey.startsWith("$")) { - jskey = jskey.slice(1); - } - const pythonDelAttr = makePythonFunction("delattr"); - pythonDelAttr(jsobj, jskey); - return true; - }, - ownKeys(jsobj) { - const pythonDir = makePythonFunction("dir"); - const result = pythonDir(jsobj).toJs(); - result.push(...Reflect.ownKeys(jsobj)); - return result; - }, - apply(jsobj: PyProxy & Function, jsthis: any, jsargs: any): any { - return jsobj.apply(jsthis, jsargs); - }, - }; - -And the base class has the following methods: - -.. code-block:: javascript +By default we implement proxy handlers for ``has``, ``get``, ``set``, and +``deleteProperty`` which roughly turn into ``hasattr()``, ``getattr()``, +``setattr()``, and ``delattr``. We also include an ``ownKeys`` handler which +calls ``dir()``. - class PyProxy { - constructor() { - throw new TypeError("PyProxy is not a constructor"); - } - get [Symbol.toStringTag]() { - return "PyProxy"; - } - static [Symbol.hasInstance](obj: any): obj is PyProxy { - return [PyProxy, PyProxyFunction].some((cls) => - Function.prototype[Symbol.hasInstance].call(cls, obj), - ); - } - get type() { - const pythonType = makePythonFunction(` - def python_type(obj): - ty = type(obj) - if ty.__module__ in ['builtins', 'main']: - return ty.__name__ - return ty.__module__ + "." + ty.__name__ - `); - return pythonType(this); - } - toString() { - const pythonStr = makePythonFunction("str"); - return pythonStr(this); - } - destroy(options) { - const { shared } = proxy[pyproxyAttrsSymbol]; - if (!shared.pyObjectPtr) { - // already destroyed - return; - } - shared.pyObjectPtr = 0; - shared.destroyed_msg = options.message ?? "Object has already been destroyed"; - _Py_DecRef(shared.pyObjectPtr); - } - [Symbol.dispose]() { - this.destroy(); - } - copy() { - const { shared, props } = proxy[pyproxyAttrsSymbol]; - // Don't pass shared as an option since we want this new PyProxy to - // have a distinct lifetime from the one we are copying. - return createPyProxy(shared.pyObjectPtr, { - flags: this.$$flags, - props: attrs.props, - }); - } - toJs(options) { - // See the definition of to_js in "Deep conversions". - } - } - -Determining Which Flags to Set -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The base class has a ``type`` field which returns roughly +``type(obj).__module__ + type(obj).__name__``. -We separate this out into a component ``get_type_flags`` that computes flags -which only depends on the type and a component that also depends on whether the -``PyProxy`` has beenJsJson +It has a ``toString()`` method which calls ``str()`` and a ``toJs()`` method +which performs a deep conversion to JavaScript. There are also methods +``destroy()``, ``[Symbol.dispose]`` and ``copy()`` which manage theq lifetime of +the proxy. -.. code-block:: python - - def get_type_flags(ty): - from collections.abc import Generator, MutableSequence, Sequence - - flags = 0 - if hasattr(ty, "__len__"): - flags |= HAS_LENGTH - if hasattr(ty, "__getitem__"): - flags |= HAS_GET - if hasattr(ty, "__setitem__"): - flags |= HAS_SET - if hasattr(ty, "__contains__"): - flags |= HAS_CONTAINS - if ty is dict: - # Currently we don't set this on subclasses. - flags |= IS_DICT - if hasattr(ty, "__call__"): - flags |= IS_CALLABLE - if hasattr(ty, "__iter__"): - flags |= IS_ITERABLE - if hasattr(ty, "__next__"): - flags |= IS_ITERATOR - if issubclass(ty, Generator): - flags |= IS_GENERATOR - if issubclass(ty, Sequence): - flags |= IS_SEQUENCE - if issubclass(ty, MutableSequence): - flags |= IS_MUTABLE_SEQUENCE - return flags - - def get_pyproxy_flags(obj, is_js_json): - flags = get_type_flags(type(obj)) - if not is_js_json: - return flags - if flags & IS_SEQUENCE: - flags |= IS_JS_JSON_SEQUENCE - elif flags & HAS_GET: - flags |= IS_JS_JSON_DICT - return flags - - -The ``HAS_GET`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonGetItem = makePythonFunction(` - def getitem(obj, key): - return obj[key] - `); - class PyProxyGetItemMixin { - get(key) { - let result = pythonGetItem(this, key); - const isJsJson = !!(this.$$flags & (IS_JS_JSON_DICT | IS_JS_JSON_SEQUENCE)); - if (isJsJson && result.asJsJson) { - result = result.asJsJson(); - } - return result; - } - asJsJson() { - const flags = this.$$flags | IS_JS_JSON_DICT; - const { shared, props } = this[pyproxyAttrsSymbol]; - // Note: The PyProxy created here has the same lifetime as the PyProxy it is - // created from. Destroying either destroys both. - return createPyProxy(shared.ptr, { flags, shared, props }); - } - } - -The ``HAS_SET`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - class PyProxySetItemMixin { - set(key, value) { - const pythonSetItem = makePythonFunction(` - def setitem(obj, key, value): - obj[key] = value - `); - pythonSetItem(this, key, value); - } - delete(key) { - const pythonDelItem = makePythonFunction(` - def delitem(obj, key): - del obj[key] - `); - pythonDelItem(this, key); - } - } - -The ``HAS_CONTAINS`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonHasItem = makePythonFunction(` - def hasitem(obj, key): - return key in obj - `); - class PyContainsMixin { - has(key) { - return pythonHasItem(this, key); - } - } - -The ``HAS_LENGTH`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonLength = makePythonFunction("len"); - class PyLengthMixin { - get length() : number { - return pythonLength(this); - } - } - -The ``IS_CALLABLE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -We have to make a custom prototype and class so that this inherits from both -``PyProxy`` and ``Function``: - -.. code-block:: javascript - - const PyProxyFunctionProto = Object.create( - Function.prototype, - Object.getOwnPropertyDescriptors(PyProxy.prototype), - ); - function PyProxyFunction() {} - PyProxyFunction.prototype = PyProxyFunctionProto; - -We use the following helper function which inserts ``this`` as the first -argument if ``captureThis`` is ``true`` and adds any bound arguments. - -.. code-block:: javascript - - function _adjustArgs(pyproxy, jsthis, jsargs) { - const { props } = this[pyproxyAttrsSymbol]; - const { captureThis, boundArgs, boundThis, isBound } = props; - if (captureThis) { - if (isBound) { - return [boundThis].concat(boundArgs, jsargs); - } else { - return [jsthis].concat(jsargs); - } - } - if (isBound) { - return boundArgs.concat(jsargs); - } - return jsargs; - } - -Then we implement the following methods. ``apply()``, ``call()``, and ``bind()`` -are methods from ``Function.prototype``. ``callKwargs()`` and ``captureThis()`` -are special to ``PyProxy`` - - -.. code-block:: javascript - - export class PyCallableMixin { - apply(thisArg, jsargs) { - // Convert jsargs to an array using ordinary .apply in order to match the - // behavior of .apply very accurately. - jsargs = function (...args) { - return args; - }.apply(undefined, jsargs); - jsargs = _adjustArgs(this, thisArg, jsargs); - const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr; - return callPyObjectKwargs(pyObjectPtr, jsargs, {}); - } - call(thisArg, ...jsargs) { - jsargs = _adjustArgs(this, thisArg, jsargs); - const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr; - return callPyObjectKwargs(pyObjectPtr, jsargs, {}); - } - - /** - * Call the function with keyword arguments. The last argument must be an - * object with the keyword arguments. - */ - callKwargs(...jsargs) { - jsargs = _adjustArgs(this, thisArg, jsargs); - if (jsargs.length === 0) { - throw new TypeError( - "callKwargs requires at least one argument (the kwargs object)", - ); - } - let kwargs = jsargs.pop(); - if ( - kwargs.constructor !== undefined && - kwargs.constructor.name !== "Object" - ) { - throw new TypeError("kwargs argument is not an object"); - } - const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr; - return callPyObjectKwargs(pyObjectPtr, jsargs, kwargs); - } - /** - * This is our implementation of Function.prototype.bind(). - */ - bind(thisArg, ...jsargs) { - let { shared, props } = this[pyproxyAttrsSymbol]; - const { boundArgs: boundArgsOld, boundThis: boundThisOld, isBound } = props; - let boundThis = thisArg; - if (isBound) { - boundThis = boundThisOld; - } - const boundArgs = boundArgsOld.concat(jsargs); - props = Object.assign({}, props, { - boundArgs, - isBound: true, - boundThis, - }); - return createPyProxy(shared.ptr, { - shared, - flags: this.$$flags, - props, - }); - } - /** - * This method makes a new PyProxy where ``this`` is passed as the - * first argument to the Python function. The new PyProxy has the - * same lifetime as the original. - */ - captureThis() { - let { props, shared } = this[pyproxyAttrsSymbol]; - props = Object.assign({}, props, { - captureThis: true, - }); - return createPyProxy(shared.ptr, { - shared, - flags: this.$$flags, - props, - }); - } - } - - -The ``IS_DICT`` Mixin -~~~~~~~~~~~~~~~~~~~~~ - -The ``IS_DICT`` mixin does not include any extra methods but it uses a special -set of handlers. These handlers are a hybrid between the normal handlers and the -``JS_JSON_DICT`` handlers. We first check whether ``hasattr(d, property)`` and -if so return ``d.property``. If not, we return ``d.get(property, None)``. The -other methods all work similarly. See the ``IS_JS_JSON_DICT`` flag for the -definitions of those handlers. - -.. code-block:: javascript - - const PyProxyDictHandlers = { - isExtensible(): boolean { - return true; - }, - has(jsobj: PyProxy, jskey: string | symbol): boolean { - if (PyProxyHandlers.has(jsobj, jskey)) { - return true; - } - return PyProxyJsJsonDictHandlers.has(jsobj, jskey); - }, - get(jsobj: PyProxy, jskey: string | symbol): any { - let result = PyProxyHandlers.get(jsobj, jskey); - if (result !== undefined || PyProxyHandlers.has(jsobj, jskey)) { - return result; - } - return PyProxyJsJsonDictHandlers.get(jsobj, jskey); - }, - set(jsobj: PyProxy, jskey: string | symbol, jsval: any): boolean { - if (PyProxyHandlers.has(jsobj, jskey)) { - return PyProxyHandlers.set(jsobj, jskey, jsval); - } - return PyProxyJsJsonDictHandlers.set(jsobj, jskey, jsval); - }, - deleteProperty(jsobj: PyProxy, jskey: string | symbol): boolean { - if (PyProxyHandlers.has(jsobj, jskey)) { - return PyProxyHandlers.deleteProperty(jsobj, jskey); - } - return PyProxyJsJsonDictHandlers.deleteProperty(jsobj, jskey); - }, - getOwnPropertyDescriptor(jsobj: PyProxy, prop: any) { - return ( - Reflect.getOwnPropertyDescriptor(jsobj, prop) ?? - PyProxyJsJsonDictHandlers.getOwnPropertyDescriptor(jsobj, prop) - ); - }, - ownKeys(jsobj: PyProxy): (string | symbol)[] { - const result = [ - ...PyProxyHandlers.ownKeys(jsobj), - ...PyProxyJsJsonDictHandlers.ownKeys(jsobj) - ]; - // deduplicate - return Array.from(new Set(result)); - }, - }; - - -The ``IS_ITERABLE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonNext = makePythonFunction("next"); - const getStopIterationValue = makePythonFunction(` - def get_stop_iteration_value(): - import sys - err = sys.last_value - return err.value - `); - - function* iterHelper(iter, isJsJson) { - try { - while (true) { - let item = pythonNext(iter); - if (isJsJson && item.asJsJson) { - item = item.asJsJson(); - } - yield item; - } - } catch (e) { - if (e.type === "StopIteration") { - return getStopIterationValue(); - } - throw e; - } - } - - const pythonIter = makePythonFunction("iter"); - class PyIterableMixin { - [Symbol.iterator]() { - const isJsJson = !!(this.$$flags & (IS_JS_JSON_DICT | IS_JS_JSON_SEQUENCE)); - return iterHelper(pythonIter(this), isJsJson); - } - } - - -The ``IS_ITERATOR`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonSend = makePythonFunction(` - def python_send(it, val): - return gen.send(val) - `); - class PyIteratorMixin { - next(x) { - try { - const result = pythonSend(this, x); - return { done: false, value: result }; - } catch (e) { - if (e.type === "StopIteration") { - const result = getStopIterationValue(); - return { done: true, value: result }; - } - throw e; - } - } - } - - -The ``IS_GENERATOR`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: javascript - - const pythonThrow = makePythonFunction(` - def python_throw(gen, val): - return gen.throw(val) - `); - const pythonClose = makePythonFunction(` - def python_close(gen): - return gen.close() - `); - class PyGeneratorMixin extends PyIteratorMixin { - throw(exc) { - try { - const result = pythonThrow(this, exc); - return { done: false, value: result }; - } catch (e) { - if (e.type === "StopIteration") { - const result = getStopIterationValue(); - return { done: true, value: result }; - } - throw e; - } - } - return(value) { - pythonClose(this); - return { done: true, value } - } - } - - -The ``IS_SEQUENCE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~ - -We define all of the ``Array.prototype`` methods that don't mutate the sequence -on ``PySequenceMixin``. For most of them, the ``Array`` prototype method works -without changes. All of these we define with boilerplate of the form: - -.. code-block:: javascript - - [methodName](...args) { - return Array.prototype[methodName].call(this, ...args) - } - -These include ``join``, ``slice``, ``indexOf``, ``lastIndexOf``, ``forEach``, -``map``, ``filter``, ``some``, ``every``, ``reduce``, ``reduceRight``, ``at``, -``concat``, ``includes``, ``entries``, ``keys``, ``values``, ``find``, and -``findIndex``. Other than these boilerplate methods, the remaining attributes on -``PySequenceMixin`` are as follows. - -.. code-block:: javascript - - class PySequenceMixin { - get [Symbol.isConcatSpreadable]() { - return true; - } - toJSON() { - return Array.from(this); - } - asJsJson() { - const flags = this.$$flags | IS_JS_JSON_SEQUENCE; - const { shared, props } = this[pyproxyAttrsSymbol]; - // Note: Because we pass shared down, the PyProxy created here has - // the same lifetime as the PyProxy it is created from. Destroying - // either destroys both. - return createPyProxy(shared.ptr, { flags, shared, props }); - } - // ... boilerplate methods - } - - -Instead of the default proxy handlers, we use the following handlers for -sequences. We don't - -.. code-block:: javascript - - const PyProxySequenceHandlers = { - isExtensible() { - return true; - }, - has(jsobj, jskey) { - if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) { - // Note: if the number was negative it didn't match the pattern - return Number(jskey) < jsobj.length; - } - return PyProxyHandlers.has(jsobj, jskey); - }, - get(jsobj, jskey) { - if (jskey === "length") { - return jsobj.length; - } - if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) { - try { - return PyProxyGetItemMixin.prototype.get.call(jsobj, Number(jskey)); - } catch (e) { - if (isPythonError(e) && e.type == "IndexError") { - return undefined; - } - throw e; - } - } - return PyProxyHandlers.get(jsobj, jskey); - }, - set(jsobj: PyProxy, jskey: any, jsval: any): boolean { - if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) { - try { - PyProxySetItemMixin.prototype.set.call(jsobj, Number(jskey), jsval); - return true; - } catch (e) { - if (isPythonError(e) && e.type == "IndexError") { - return false; - } - throw e; - } - } - return PyProxyHandlers.set(jsobj, jskey, jsval); - }, - deleteProperty(jsobj: PyProxy, jskey: any): boolean { - if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) { - try { - PyProxySetItemMixin.prototype.delete.call(jsobj, Number(jskey)); - return true; - } catch (e) { - if (isPythonError(e) && e.type == "IndexError") { - return false; - } - throw e; - } - } - return PyProxyHandlers.deleteProperty(jsobj, jskey); - }, - ownKeys(jsobj: PyProxy): (string | symbol)[] { - const result = PyProxyHandlers.ownKeys(jsobj); - result.push( - ...Array.from({ length: jsobj.length }, (_, k) => k.toString()), - ); - result.push("length"); - return result; - }, - }; - - -The ``IS_MUTABLE_SEQUENCE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This adds some additional ``Array`` methods that mutate the sequence. - -.. code-block:: javascript - - class PyMutableSequenceMixin { - reverse() { - // Same as the Python reverse method except it returns this instead of undefined - this.$reverse(); - return this; - } - push(...elts: any[]) { - for (const elt of elts) { - this.append(elt); - } - return this.length; - } - splice(start, deleteCount, ...items) { - if (deleteCount === undefined) { - // Max signed size - deleteCount = (1 << 31) - 1; - } - let stop = start + deleteCount; - if (stop > this.length) { - stop = this.length; - } - const pythonSplice = makePythonFunction(` - def splice(array, start, stop, items): - from jstypes.ffi import to_js - result = to_js(array[start:stop], depth=1) - array[start:stop] = items - return result - `); - return pythonSplice(this, start, stop, items); - } - pop() { - const pythonPop = makePythonFunction(` - def pop(array): - return array.pop() - `); - return pythonPop(this); - } - shift() { - const pythonShift = makePythonFunction(` - def pop(array): - return array.pop(0) - `); - return pythonShift(this); - } - unshift(...elts) { - elts.forEach((elt, idx) => { - this.insert(idx, elt); - }); - return this.length; - } - // Boilerplate methods - copyWithin(...args): any { - Array.prototype.copyWithin.apply(this, args); - return this; - } - fill(...args) { - Array.prototype.fill.apply(this, args); - return this; - } - } - -The ``IS_JS_JSON_DICT`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -There are no methods special to the ``IS_JS_JSON_DICT`` flag, but we use the -following proxy handlers. We prefer to look up a property as an item in the -dictionary with two exceptions: - -1. Symbols we always look up on the ``PyProxy`` itself. -2. We also look up the keys ``$$flags``, ``copy()``, ``constructor``, - ``destroy`` and ``toString`` on the ``PyProxy``. - -All Python dictionary methods will be shadowed by a key of the same name. - -.. code-block:: javascript - - const PyProxyJsJsonDictHandlers = { - isExtensible(): boolean { - return true; - }, - has(jsobj: PyProxy, jskey: string | symbol): boolean { - if (PyContainsMixin.prototype.has.call(jsobj, jskey)) { - return true; - } - // If it doesn't exist as a string key and it looks like a number, - // try again with the number - if (typeof jskey === "string" && /^-?[0-9]+$/.test(jskey)) { - return PyContainsMixin.prototype.has.call(jsobj, Number(jskey)); - } - return false; - }, - get(jsobj, jskey): any { - if ( - typeof jskey === "symbol" || - ["$$flags", "copy", "constructor", "destroy", "toString"].includes(jskey) - ) { - return Reflect.get(...arguments); - } - const result = PyProxyGetItemMixin.prototype.get.call(jsobj, jskey); - if ( - result !== undefined || - PyContainsMixin.prototype.has.call(jsobj, jskey) - ) { - return result; - } - if (typeof jskey === "string" && /^-?[0-9]+$/.test(jskey)) { - return PyProxyGetItemMixin.prototype.get.call(jsobj, Number(jskey)); - } - return Reflect.get(...arguments); - }, - set(jsobj, jskey, jsval): boolean { - if (typeof jskey === "symbol") { - return false; - } - if ( - !PyContainsMixin.prototype.has.call(jsobj, jskey) && - typeof jskey === "string" && - /^-?[0-9]+$/.test(jskey) - ) { - jskey = Number(jskey); - } - try { - PyProxySetItemMixin.prototype.set.call(jsobj, jskey, jsval); - return true; - } catch (e) { - if (isPythonError(e) && e.type === "KeyError") { - return false; - } - throw e; - } - }, - deleteProperty(jsobj: PyProxy, jskey: string | symbol | number): boolean { - if (typeof jskey === "symbol") { - return false; - } - if ( - !PyContainsMixin.prototype.has.call(jsobj, jskey) && - typeof jskey === "string" && - /^-?[0-9]+$/.test(jskey) - ) { - jskey = Number(jskey); - } - try { - PyProxySetItemMixin.prototype.delete.call(jsobj, jskey); - return true; - } catch (e) { - if (isPythonError(e) && e.type === "KeyError") { - return false; - } - throw e; - } - }, - getOwnPropertyDescriptor(jsobj: PyProxy, prop: any) { - if (!PyProxyJsJsonDictHandlers.has(jsobj, prop)) { - return undefined; - } - const value = PyProxyJsJsonDictHandlers.get(jsobj, prop); - return { - configurable: true, - enumerable: true, - value, - writable: true, - }; - }, - ownKeys(jsobj: PyProxy): (string | symbol)[] { - const pythonDictOwnKeys = makePythonFunction(` - def dict_own_keys(d): - from jstypes.ffi import to_js - result = set() - for key in d: - if isinstance(key, str): - result.add(key) - elif isinstance(key, (int, float)): - result.add(str(key)) - return to_js(result) - `); - return pythonDictOwnKeys(jsobj); - }, - }; - - -The ``IS_JS_JSON_SEQUENCE`` Mixin -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This has no direct impact on the prototype or handlers of the proxy. However, -when indexing the list or iterating over the list we will apply ``asJsJson()`` -to the results. Deep Conversions ---------------- @@ -2921,7 +1086,7 @@ The default converter takes three arguments: The object to convert. ``convert`` - Allows recursing. + A function that converts the argument with the same settings. Allows recursing. ``cache_conversion`` Cache the conversion of an object to allow converting self-referential data. @@ -2944,281 +1109,69 @@ By first caching the result before making any recursive calls to ``convert``, we ensure that if ``jsobj.first`` has a transitive reference to ``jsobj``, we convert it correctly. -Complete pseudocode for the ``to_py`` method is as follows: -.. code-block:: python +From Python to JavaScript +~~~~~~~~~~~~~~~~~~~~~~~~~ - def to_py(jsobj, *, depth=-1, default_converter=None): - cache = {} - return ToPyConverter(depth, default_converter).convert(jsobj) - - class ToPyConverter: - def __init__(self, depth, default_converter): - self.cache = {} - self.depth = depth - self.default_converter = default_converter - - def cache_conversion(self, jsobj, pyobj): - self.cache[jsobj.js_id] = pyobj - - def convert(self, jsobj): - if self.depth == 0 or not isinstance(jsobj, JSProxy): - return jsobj - if result := self.cache.get(jsobj.js_id): - return result - - from jstypes.global_this import Array, Object - type_tag = getTypeTag(jsobj) - self.depth -= 1 - try: - if Array.isArray(jsobj): - return self.convert_list(jsobj) - if type_tag == "[object Map]": - return self.convert_map(jsobj, jsobj.entries()) - if type_tag == "[object Set]": - return self.convert_set(jsobj) - if type_tag == "[object Object]" and (jsobj.constructor in [None, Object]): - return self.convert_map(jsobj, Object.entries(jsobj)) - if self.default_converter is not None: - return self.default_converter(jsobj, self.convert, self.cache_conversion) - return jsobj - finally: - self.depth += 1 - - def convert_list(self, jsobj): - result = [] - self.cache_conversion(jsobj, result) - for item in jsobj: - result.append(self.convert(item)) - return result - - def convert_map(self, jsobj, entries): - result = {} - self.cache_conversion(jsobj, result) - for [key, val] in entries: - result[key] = self.convert(val) - return result - - def convert_set(self, jsobj): - result = set() - self.cache_conversion(jsobj, result) - for key in jsobj: - result.add(self.convert(key)) - return result +The ``jstypes.ffi.to_js()`` method makes the following conversions: +* ``list`` or any ``Sequence`` that doesn't implement the Buffer protocol ==> + ``Array`` +* ``dict`` ==> ``object`` (can be customized with the ``dict_converter`` argument) +* ``Set`` ==> ``set`` -From Python to JavaScript -~~~~~~~~~~~~~~~~~~~~~~~~~ +Everything else is turned to a ``pyproxy`` by default. -.. code-block:: python +``to_js`` takes the following optional arguments: - def to_js( - obj, - /, - *, - depth=-1, - pyproxies=None, - create_pyproxies=True, - dict_converter=None, - default_converter=None, - eager_converter=None, - ): - converter = ToJsConverter( - depth, - pyproxies, - create_pyproxies, - dict_converter, - default_converter, - eager_converter, - ) - result = converter.convert(obj) - converter.postprocess() - return result +``depth`` + An integer, specifies the maximum depth down to which to convert. For + instance, setting ``depth=1`` allows converting exactly one level. - class ToJsConverter: - def __init__( - self, - depth, - pyproxies, - create_pyproxies, - dict_converter, - default_converter, - eager_converter, - ): - self.depth = depth - self.pyproxies = pyproxies - self.create_pyproxies = create_pyproxies - if dict_converter is None: - dict_converter = Object.fromEntries - self.dict_converter = dict_converter - self.default_converter = default_converter - self.eager_converter = eager_converter - self.cache = {} - self.post_process_list = [] - self.pairs_to_dict_map = {} - - def cache_conversion(self, pyobj, jsobj): - self.cache[id(pyobj)] = jsobj - - def postprocess(self): - # Replace any NoValue's that appear once we've certainly computed - # their correct conversions - for parent, key, pyobj_id in self.post_process_list: - real_value = self.cache[pyobj_id] - # If it was a dictionary, we need to lookup the actual result object - real_parent = self.pairs_to_dict_map.get(parent.js_id, parent) - real_parent[key] = real_value - - @contextmanager - def decrement_depth(self): - self.depth -= 1 - try: - yield - finally: - self.depth += 1 - - def convert(self, pyobj): - if self.depth == 0 or isinstance(pyobj, JSProxy): - return pyobj - if result := self.cache.get(id(pyobj)): - return result - - with self.decrement_depth(): - if self.eager_converter: - return self.eager_converter( - pyobj, self.convert_no_eager_public, self.cache_conversion - ) - return self.convert_no_eager(pyobj) - - def convert_no_eager_public(self, pyobj): - with self.decrement_depth(): - return self.convert_no_eager(pyobj) - - def convert_no_eager(self, pyobj): - if isinstance(pyobj, (tuple, list)): - return self.convert_sequence(pyobj) - if isinstance(pyobj, dict): - return self.convert_dict(pyobj) - if isinstance(pyobj, set): - return self.convert_set(pyobj) - if self.default_converter: - return self.default_converter( - pyobj, self.convert_no_eager_public, self.cache_conversion - ) - if not self.create_pyproxies: - raise ConversionError( - f"No conversion available for {pyobj!r} and create_pyproxies=False passed" - ) - result = create_proxy(pyobj) - if self.pyproxies is not None: - self.pyproxies.append(result) - return result - - def convert_sequence(self, pyobj): - from jstypes.global_this import Array - - result = Array.new() - self.cache_conversion(pyobj, result) - for idx, val in enumerate(pyobj): - converted = self.convert(val) - if converted is NoValue: - self.post_process_list.append((result, idx, id(val))) - result.push(converted) - return result - - def convert_dict(self, pyobj): - from jstypes.global_this import Array - - # Temporarily store NoValue in the cache since we only get the - # actual value from dict_converter. We'll replace these with the - # correct values in the postprocess step - self.cache_conversion(pyobj, NoValue) - pairs = Array.new() - for [key, value] in pyobj.items(): - converted = self.convert(value) - if converted is NoValue: - self.post_process_list.append((pairs, key, id(value))) - pairs.push(Array.new(key, converted)) - result = self.dict_converter(pairs) - self.pairs_to_dict_map[pairs.js_id] = result - # Update the cache to point to the actual result - self.cache_conversion(pyobj, result) - return result - - def convert_set(self, pyobj): - from jstypes.global_this import Set - result = Set.new() - self.cache_conversion(pyobj, result) - for key in pyobj: - if isinstance(key, JSProxy): - raise ConversionError( - f"Cannot use {key!r} as a key for a JavaScript Set" - ) - result.add(key) - return result +``pyproxies`` + If passed, this should be a JavaScript Array. Every ``PyProxy`` created by + this conversion will be added to this Array. This allows destroying all + proxies created by the conversion when done using the conversion result. +``create_pyproxies`` + If this is ``False``, ``to_js`` will raise an error instead of creating a + ``PyProxy``. This ensures that on success the object was fully converted to + JavaScript. -The ``jstypes.global_this`` Module ----------------------------------- +``dict_converter`` + Changes the way that dictionaries are converted to JavaScript. By default + they are converted to JavaScript ``Object``. This should be a function which + takes a JavaScript iterable of JavaScript ``[key, value]`` pairs and retuns + the conversion result. -The ``jstypes.global_this`` module allows us to import objects from JavaScript. The definition is -as follows: +``eager_converter`` + This is called on an object before the default conversions are applied and + allows overriding how objects with native conversions are converted. -.. code:: python +``default_converter`` + This is called on an object if no native conversion applies to it. - import sys - from jstypes.code import run_js - from jstypes.ffi import JSProxy - from importlib.abc import Loader, MetaPathFinder - from importlib.util import spec_from_loader - - class JSLoader(Loader): - def __init__(self, jsproxy): - self.jsproxy = jsproxy - - def create_module(self, spec): - return self.jsproxy - - def exec_module(self, module): - pass - - def is_package(self, fullname): - return True - - class JSFinder(MetaPathFinder): - def _get_object(self, fullname): - [parent, _, child] = fullname.rpartition(".") - if not parent: - if child == "jstypes": - return run_js("globalThis") - return None - - parent_module = sys.modules[parent] - if not isinstance(parent_module, JSProxy): - # Not one of us. - return None - jsproxy = getattr(parent_module, child, None) - if not isinstance(jsproxy, JSProxy): - raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) - return jsproxy - - def find_spec( - self, - fullname, - path, - target, - ): - jsproxy = self._get_object(fullname) - loader = JSLoader(jsproxy) - return spec_from_loader(fullname, loader, origin="javascript") - - - finder = JSFinder() - sys.meta_path.insert(0, finder) - del sys.modules["jstypes.global_this"] - import jstypes.global_this - sys.meta_path.remove(finder) - sys.meta_path.append(finder) + +The ``default_converter`` and ``eager_converter`` functions take three arguments: + +``pyobject`` + The object to convert to JavaScript. + +``converter`` + The converter function, used for recursing. + +``cache`` + Cache the conversion of an object to allow converting self-referential data. + + +The ``jstypes.global_this`` Module +---------------------------------- + +The ``jstypes.global_this`` module is a reference to the JavaScript +``globalThis`` object. ``globalThis`` is the JavaScript equivalent of the Python +``builtins`` module. The exact set of properties defined on it depends on the +JavaScript runtime and any additional properties that have been added by user +code. The ``jstypes`` package ----------------------- @@ -3264,59 +1217,7 @@ This has the following properties: ``JSProxy``: This is ``type(run_js("({})"))`` -``JSBigInt``: This is defined as follows: - -.. code-block:: python - - def _int_to_bigint(x): - if isinstance(x, int): - return JSBigInt(x) - return x - - class JSBigInt(int): - # unary ops - def __abs__(self): - return JSBigInt(int.__abs__(self)) - - def __invert__(self): - return JSBigInt(int.__invert__(self)) - - def __neg__(self): - return JSBigInt(int.__neg__(self)) - - def __pos__(self): - return JSBigInt(int.__pos__(self)) - - # binary ops - def __add__(self, other): - return _int_to_bigint(int.__add__(self, other)) - - def __and__(self, other): - return _int_to_bigint(int.__and__(self, other)) - - def __floordiv__(self, other): - return _int_to_bigint(int.__floordiv__(self, other)) - - def __lshift__(self, other): - return _int_to_bigint(int.__lshift__(self, other)) - - def __mod__(self, other): - return _int_to_bigint(int.__mod__(self, other)) - - def __or__(self, other): - return _int_to_bigint(int.__or__(self, other)) - - def __pow__(self, other, modulus = None): - return _int_to_bigint(int.__pow__(self, other, modulus)) - - def __rshift__(self, other): - return _int_to_bigint(int.__rshift__(self, other)) - - def __sub__(self, other): - return _int_to_bigint(int.__sub__(self, other)) - - def __xor__(self, other): - return _int_to_bigint(int.__xor__(self, other)) +``JSBigInt`` The ``jstypes.code`` Module