Handling for reserved Python keywords as JsProxy attrs (#3617)

Resolves #3615. Things like `from` and `finally` are reserved keywords in Python and so `a.finally` 
is a `SyntaxError`. This automatically reroutes `a.from_` on a `JsProxy` to refer to `a.from` so it
can be used reasonably conveniently from Python.
This commit is contained in:
Hood Chatham 2023-03-03 11:13:40 +01:00 committed by GitHub
parent ce7880739e
commit 97d17373f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 149 additions and 14 deletions

View File

@ -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`

View File

@ -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

View File

@ -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.",

View File

@ -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);

View File

@ -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")