diff --git a/docs/project/changelog.md b/docs/project/changelog.md index 668d6eb29..4676b7d18 100644 --- a/docs/project/changelog.md +++ b/docs/project/changelog.md @@ -15,6 +15,15 @@ myst: ## Unreleased +- {{ Enhancement }} Python does not allow reserved words to be used as attributes. + For instance, `Array.from` is a `SyntaxError`. (JavaScript has a more robust + parser which can handle this.) To handle this, if an attribute to a `JsProxy` + consists of a Python reserved word followed by one or more underscores, we remove + a single underscore from the end of the attribute. For instance, `Array.from_` + would access `from` on the underlying JavaScript object, whereas `o.from__` + accesses the `from_` attribute. + {pr}`3617` + - {{ Enhancement }} `runPython` and `runPythonAsync` now accept a `locals` argument. {pr}`3618` diff --git a/docs/usage/type-conversions.md b/docs/usage/type-conversions.md index 2953ad99e..406a59a3f 100644 --- a/docs/usage/type-conversions.md +++ b/docs/usage/type-conversions.md @@ -208,6 +208,20 @@ obj_map = obj.as_object_map() assert obj_map["$c"] == 11 ``` +Another special case comes from the fact that Python reserved words cannot be +used as attributes. For instance, {js:func}`Array.from` and +{js:meth}`Promise.finally` cannot be directly accessed because they are Python +`SyntaxError`s. Instead we access these attributes with `Array.from_` and +`Promise.finally_`. Similarly, to access from Python, `o.from_` you have to use +`o.from__` with two underscores (since a single underscore is used for +`o.from`). This is reflected in the `dir` of a `JsProxy`: + +```py +from pyodide.code import run_js +o = run_js("({finally: 1, return: 2, from: 3, from_: 4})") +assert set(dir(o)) == {"finally_", "return_", "from_", "from__"} +``` + (type-translations-pyproxy)= ### Proxying from Python into JavaScript diff --git a/src/core/error_handling.ts b/src/core/error_handling.ts index dfb9de4b8..baef6fb8d 100644 --- a/src/core/error_handling.ts +++ b/src/core/error_handling.ts @@ -307,7 +307,6 @@ API.PythonError = PythonError; // appropriate error value (either NULL or -1). class _PropagatePythonError extends Error { constructor() { - API.fail_test = true; super( "If you are seeing this message, an internal Pyodide error has " + "occurred. Please report it to the Pyodide maintainers.", diff --git a/src/core/hiwire.c b/src/core/hiwire.c index 74b180813..e66a32e0a 100644 --- a/src/core/hiwire.c +++ b/src/core/hiwire.c @@ -908,25 +908,74 @@ EM_JS_REF(JsRef, JsObject_New, (), { }); // clang-format on +void +setReservedError(char* action, char* word) +{ + PyErr_Format(PyExc_AttributeError, + "The string '%s' is a Python reserved word. To %s an attribute " + "on a JS object called '%s' use '%s_'.", + word, + action, + word, + word); +} + +EM_JS(bool, isReservedWord, (int word), { + if (!Module.pythonReservedWords) { + Module.pythonReservedWords = new Set([ + "False", "await", "else", "import", "pass", "None", "break", + "except", "in", "raise", "True", "class", "finally", "is", + "return", "and", "continue", "for", "lambda", "try", "as", + "def", "from", "nonlocal", "while", "assert", "del", "global", + "not", "with", "async", "elif", "if", "or", "yield", + ]) + } + return Module.pythonReservedWords.has(word); +}) + +/** + * action: a javascript string, one of get, set, or delete. For error reporting. + * word: a javascript string, the property being accessed + */ +EM_JS(int, normalizeReservedWords, (int action, int word), { + // clang-format off + // 1. if word is not a reserved word followed by 0 or more underscores, return + // it unchanged. + const noTrailing_ = word.replace(/_*$/, ""); + if (!isReservedWord(noTrailing_)) { + return word; + } + // 2. If there is at least one trailing underscore, return the word with a + // single underscore removed. + if (noTrailing_ !== word) { + return word.slice(0, -1); + } + // 3. If the word is exactly a reserved word, this is an error. + let action_ptr = stringToNewUTF8(action); + let word_ptr = stringToNewUTF8(word); + _setReservedError(action_ptr, word_ptr); + _free(action_ptr); + _free(word_ptr); + throw new Module._PropagatePythonError(); + // clang-format on +}); + EM_JS_REF(JsRef, JsObject_GetString, (JsRef idobj, const char* ptrkey), { let jsobj = Hiwire.get_value(idobj); - let jskey = UTF8ToString(ptrkey); - let result = jsobj[jskey]; - // clang-format off - if (result === undefined && !(jskey in jsobj)) { - // clang-format on - return ERROR_REF; + let jskey = normalizeReservedWords("get", UTF8ToString(ptrkey)); + if (jskey in jsobj) { + return Hiwire.new_value(jsobj[jskey]); } - return Hiwire.new_value(result); + return ERROR_REF; }); // clang-format off EM_JS_NUM(errcode, - JsObject_SetString, - (JsRef idobj, const char* ptrkey, JsRef idval), +JsObject_SetString, +(JsRef idobj, const char* ptrkey, JsRef idval), { let jsobj = Hiwire.get_value(idobj); - let jskey = UTF8ToString(ptrkey); + let jskey = normalizeReservedWords("set", UTF8ToString(ptrkey)); let jsval = Hiwire.get_value(idval); jsobj[jskey] = jsval; }); @@ -934,7 +983,7 @@ EM_JS_NUM(errcode, EM_JS_NUM(errcode, JsObject_DeleteString, (JsRef idobj, const char* ptrkey), { let jsobj = Hiwire.get_value(idobj); - let jskey = UTF8ToString(ptrkey); + let jskey = normalizeReservedWords("delete", UTF8ToString(ptrkey)); delete jsobj[jskey]; }); @@ -943,12 +992,16 @@ EM_JS_REF(JsRef, JsObject_Dir, (JsRef idobj), { let result = []; do { // clang-format off - result.push(... Object.getOwnPropertyNames(jsobj).filter( + const names = Object.getOwnPropertyNames(jsobj); + result.push(...names.filter( s => { let c = s.charCodeAt(0); return c < 48 || c > 57; /* Filter out integer array indices */ } - )); + ) + // If the word is a reserved word followed by 0 or more underscores, add an + // extra underscore to reverse the transformation applied by normalizeReservedWords. + .map(word => isReservedWord(word.replace(/_*$/, "")) ? word + "_" : word)); // clang-format on } while (jsobj = Object.getPrototypeOf(jsobj)); return Hiwire.new_value(result); diff --git a/src/tests/test_jsproxy.py b/src/tests/test_jsproxy.py index 3f50338f3..85ecbd352 100644 --- a/src/tests/test_jsproxy.py +++ b/src/tests/test_jsproxy.py @@ -2197,3 +2197,63 @@ async def test_agen_lifetimes(selenium): del res assert v == ["{1}", "{2}", "{3}", "{4}"] assert sys.getrefcount(v) == 2 + + +@run_in_pyodide +def test_python_reserved_keywords(selenium): + import pytest + from pyodide.code import run_js + + o = run_js( + """({ + async: 1, + await: 2, + False: 3, + nonlocal: 4, + yield: 5, + try: 6, + assert: 7, + match: 222, + }) + """ + ) + assert o.match == 222 + with pytest.raises(AttributeError): + o.match_ + assert eval("o.match") == 222 + keys = ["async", "await", "False", "nonlocal", "yield", "try", "assert"] + for k in keys: + with pytest.raises(SyntaxError): + eval(f"o.{k}") + + assert o.async_ == 1 + assert o.await_ == 2 + assert o.False_ == 3 + assert o.nonlocal_ == 4 + assert o.yield_ == 5 + assert o.try_ == 6 + assert o.assert_ == 7 + expected_set = {k + "_" for k in keys} | {"match"} + actual_set = set(dir(o)) & expected_set + assert actual_set == expected_set + assert set(dir(o)) & set(keys) == set() + o.async_ = 2 + assert run_js("(o) => o.async")(o) == 2 + del o.async_ + assert run_js("(o) => o.async")(o) == None + + o = run_js("({async: 1, async_: 2, async__: 3})") + expected_set = {"async_", "async__", "async___"} + actual_set = set(dir(o)) & expected_set + assert actual_set == expected_set + assert o.async_ == 1 + assert o.async__ == 2 + assert o.async___ == 3 + assert getattr(o, "async_") == 1 + assert getattr(o, "async__") == 2 + with pytest.raises(AttributeError, match="async"): + getattr(o, "async") + with pytest.raises(AttributeError, match="reserved.*set.*'async_'"): + setattr(o, "async", 2) + with pytest.raises(AttributeError, match="reserved.*delete.*'async_'"): + delattr(o, "async")