From 6df673c79088590117c635e9e5ee5d207a0ded2f Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 26 May 2026 18:27:00 +0900 Subject: [PATCH 01/12] feat(runtime-sdk): update js object conversion logic to support cloudflare bindings more natively - Python dict is now converted to js object by default when passed through the RPC boundary. - JavaScript Null object is now converted to Python None when coming from RPC boundary. - JavaScript Object coming from RPC boundary is now converted to Python-dict like object but their attributes are accessible through ["key"] but also by .key - Better support for R2, KV, and D1 bindings support --- .../cli/tests/bindings-test/src/d1_test.py | 351 ++++++++++++++++++ .../cli/tests/bindings-test/src/kv_test.py | 263 ++++++++++++- .../cli/tests/bindings-test/src/r2_test.py | 319 ++++++++++++++++ .../cli/tests/bindings-test/src/worker.py | 4 + .../cli/tests/bindings-test/wrangler.jsonc | 10 + packages/cli/tests/test_bindings.py | 73 +++- packages/runtime-sdk/src/workers/_workers.py | 94 ++++- 7 files changed, 1094 insertions(+), 20 deletions(-) create mode 100644 packages/cli/tests/bindings-test/src/d1_test.py create mode 100644 packages/cli/tests/bindings-test/src/r2_test.py diff --git a/packages/cli/tests/bindings-test/src/d1_test.py b/packages/cli/tests/bindings-test/src/d1_test.py new file mode 100644 index 0000000..520639a --- /dev/null +++ b/packages/cli/tests/bindings-test/src/d1_test.py @@ -0,0 +1,351 @@ +TEST_TABLE = "_test_d1" +TEST_TABLE_TYPES = "_test_d1_types" +TEST_TABLE_BATCH = "_test_d1_batch" +EXEC_TABLE = "_test_d1_exec_tmp" +EXEC_MULTI_TABLE = "_test_d1_exec_multi" + + +async def _cleanup_d1(db): + for table in [TEST_TABLE, TEST_TABLE_TYPES, TEST_TABLE_BATCH, EXEC_TABLE, EXEC_MULTI_TABLE]: + try: + await db.exec(f"DROP TABLE IF EXISTS {table}") + except Exception: + pass + + +async def _ensure_tables(db): + await db.exec( + f"CREATE TABLE IF NOT EXISTS {TEST_TABLE} " + f"(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, value TEXT)" + ) + await db.exec( + f"CREATE TABLE IF NOT EXISTS {TEST_TABLE_TYPES} " + f"(id INTEGER PRIMARY KEY, txt TEXT, num REAL, intval INTEGER)" + ) + await db.exec( + f"CREATE TABLE IF NOT EXISTS {TEST_TABLE_BATCH} " + f"(id INTEGER PRIMARY KEY, val TEXT)" + ) + + +async def test_insert_and_select_via_run(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + insert_result = await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("run_test", "hello").run() + assert insert_result["success"] is True, f"insert failed: {insert_result!r}" + meta = insert_result["meta"] + assert meta["changes"] >= 1 + assert meta["last_row_id"] > 0 + row_id = meta["last_row_id"] + select_result = await db.prepare( + f"SELECT id, name, value FROM {TEST_TABLE} WHERE id = ?" + ).bind(row_id).run() + rows = select_result["results"] + assert len(rows) == 1, f"expected 1 row, got {len(rows)}" + assert rows[0]["name"] == "run_test" + assert rows[0]["value"] == "hello" + + +async def test_all_returns_results(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("all_test", "v1").run() + result = await db.prepare( + f"SELECT name, value FROM {TEST_TABLE} WHERE name = ?" + ).bind("all_test").all() + assert result["success"] is True + rows = result["results"] + assert len(rows) >= 1, f"expected >= 1 row, got {len(rows)}" + assert rows[0]["name"] == "all_test" + + +async def test_first_returns_single_row(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("first_test", "fv").run() + row = await db.prepare( + f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" + ).bind("first_test").first() + assert row is not None, "first() returned None" + assert isinstance(row, dict), f"expected dict, got {type(row)}: {row}" + assert row["name"] == "first_test" + assert row["value"] == "fv" + + +async def test_first_with_column_name(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("first_col", "col_val").run() + value = await db.prepare( + f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" + ).bind("first_col").first("value") + assert value == "col_val", f"expected 'col_val', got {value!r}" + + +async def test_first_on_empty_result(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + row = await db.prepare( + f"SELECT * FROM {TEST_TABLE} WHERE name = ?" + ).bind("__nonexistent__xyz__").first() + assert row is None, f"expected None, got {row!r}" + + +async def test_raw_returns_arrays(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("raw_test", "rv").run() + rows = await db.prepare( + f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" + ).bind("raw_test").raw() + assert isinstance(rows, list), f"expected list, got {type(rows)}" + assert len(rows) == 1, f"expected 1 row, got {len(rows)}" + assert rows[0] == ["raw_test", "rv"], f"row mismatch: {rows!r}" + + +async def test_raw_with_column_names(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("raw_cols", "rc").run() + rows = await db.prepare( + f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" + ).bind("raw_cols").raw({"columnNames": True}) + assert len(rows) == 2, f"expected header + data, got {len(rows)} rows" + assert rows[0] == ["name", "value"], f"header mismatch: {rows[0]!r}" + assert rows[1] == ["raw_cols", "rc"], f"row mismatch: {rows[1]!r}" + + +async def test_bind_types(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + + await db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" + ).bind(1, None, None, None).run() + await db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" + ).bind(2, "hello, D1!", 3.14, 42).run() + await db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)" + ).bind(3, True).run() + await db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)" + ).bind(4, False).run() + + row1 = await db.prepare( + f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ).bind(1).first() + assert row1["txt"] is None, f"expected None, got {row1['txt']!r}" + assert row1["num"] is None, f"expected None, got {row1['num']!r}" + assert row1["intval"] is None, f"expected None, got {row1['intval']!r}" + + row2 = await db.prepare( + f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ).bind(2).first() + assert row2["txt"] == "hello, D1!" + assert abs(row2["num"] - 3.14) < 0.001 + assert row2["intval"] == 42 + + row3 = await db.prepare( + f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ).bind(3).first() + row4 = await db.prepare( + f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ).bind(4).first() + assert row3["intval"] == 1, f"True should be 1, got {row3['intval']}" + assert row4["intval"] == 0, f"False should be 0, got {row4['intval']}" + + +async def test_exec_create_and_query(env): + db = env.DB + await _cleanup_d1(db) + result = await db.exec( + f"CREATE TABLE IF NOT EXISTS {EXEC_TABLE} (id INTEGER PRIMARY KEY, val TEXT)" + ) + assert result["count"] >= 1, f"expected count >= 1, got {result['count']}" + assert result["duration"] >= 0, f"expected duration >= 0, got {result['duration']}" + + +async def test_exec_multiple_statements(env): + db = env.DB + await _cleanup_d1(db) + result = await db.exec( + f"CREATE TABLE IF NOT EXISTS {EXEC_MULTI_TABLE} (id INTEGER PRIMARY KEY, val TEXT);\n" + f"INSERT INTO {EXEC_MULTI_TABLE} (val) VALUES ('a');\n" + f"INSERT INTO {EXEC_MULTI_TABLE} (val) VALUES ('b');" + ) + assert result["count"] >= 3, f"expected count >= 3, got {result['count']}" + rows = await db.prepare(f"SELECT val FROM {EXEC_MULTI_TABLE} ORDER BY val").raw() + assert rows == [["a"], ["b"]], f"row mismatch: {rows!r}" + + +async def test_batch_multiple_inserts(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + statements = [ + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(1, "batch_a"), + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(2, "batch_b"), + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(3, "batch_c"), + ] + results = await db.batch(statements) + assert results is not None, "batch returned None" + all_rows = await db.prepare( + f"SELECT id, val FROM {TEST_TABLE_BATCH} ORDER BY id" + ).all() + rows = all_rows["results"] + assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" + assert [row["val"] for row in rows] == ["batch_a", "batch_b", "batch_c"] + + +async def test_run_metadata_fields(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + result = await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("meta_test", "mv").run() + assert result["success"] is True + meta = result["meta"] + for key in ["duration", "changes", "last_row_id", "changed_db", "rows_read", "rows_written", "size_after"]: + assert key in meta, f"missing {key!r} in meta: {meta!r}" + assert meta["changes"] >= 1 + assert meta["changed_db"] is True + + +async def test_update_row(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + insert_result = await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("update_me", "old_value").run() + row_id = insert_result["meta"]["last_row_id"] + update_result = await db.prepare( + f"UPDATE {TEST_TABLE} SET value = ? WHERE id = ?" + ).bind("new_value", row_id).run() + assert update_result["meta"]["changes"] == 1 + row = await db.prepare( + f"SELECT value FROM {TEST_TABLE} WHERE id = ?" + ).bind(row_id).first() + assert row["value"] == "new_value", f"expected 'new_value', got {row['value']!r}" + + +async def test_delete_row(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + insert_result = await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("delete_me", "gone").run() + row_id = insert_result["meta"]["last_row_id"] + delete_result = await db.prepare( + f"DELETE FROM {TEST_TABLE} WHERE id = ?" + ).bind(row_id).run() + assert delete_result["meta"]["changes"] == 1 + row = await db.prepare( + f"SELECT * FROM {TEST_TABLE} WHERE id = ?" + ).bind(row_id).first() + assert row is None, f"row should be deleted, got {row!r}" + + +async def test_session_prepare_and_query(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await db.prepare( + f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" + ).bind("session_test", "sv").run() + session = db.withSession() + assert session is not None, "withSession() returned None" + result = await session.prepare( + f"SELECT COUNT(*) as cnt FROM {TEST_TABLE}" + ).all() + assert result["success"] is True + assert result["results"][0]["cnt"] >= 1 + + +async def test_session_bookmark(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + session = db.withSession() + bookmark_before = session.getBookmark() + assert bookmark_before is None, f"expected None before query, got {bookmark_before!r}" + await session.prepare(f"SELECT 1").all() + bookmark_after = session.getBookmark() + assert bookmark_after is not None, "expected bookmark after query, got None" + assert isinstance(bookmark_after, str), f"expected string, got {type(bookmark_after)}" + assert len(bookmark_after) > 0, "bookmark should be non-empty" + + +async def test_session_batch(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + session = db.withSession() + statements = [ + session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(1, "sa"), + session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(2, "sb"), + ] + results = await session.batch(statements) + assert results is not None, "session batch returned None" + rows = await session.prepare( + f"SELECT id, val FROM {TEST_TABLE_BATCH} ORDER BY id" + ).all() + assert len(rows["results"]) == 2 + assert rows["results"][0]["val"] == "sa" + assert rows["results"][1]["val"] == "sb" + + +async def test_invalid_sql_raises_error(env): + db = env.DB + await _cleanup_d1(db) + raised = False + try: + await db.prepare("INVALID SQL GIBBERISH").run() + except Exception: + raised = True + assert raised, "expected error on invalid SQL" + + +D1_TESTS = { + "insert_and_select_via_run": test_insert_and_select_via_run, + "all_returns_results": test_all_returns_results, + "first_returns_single_row": test_first_returns_single_row, + "first_with_column_name": test_first_with_column_name, + "first_on_empty_result": test_first_on_empty_result, + "raw_returns_arrays": test_raw_returns_arrays, + "raw_with_column_names": test_raw_with_column_names, + "bind_types": test_bind_types, + "exec_create_and_query": test_exec_create_and_query, + "exec_multiple_statements": test_exec_multiple_statements, + "batch_multiple_inserts": test_batch_multiple_inserts, + "run_metadata_fields": test_run_metadata_fields, + "update_row": test_update_row, + "delete_row": test_delete_row, + "session_prepare_and_query": test_session_prepare_and_query, + "session_bookmark": test_session_bookmark, + "session_batch": test_session_batch, + "invalid_sql_raises_error": test_invalid_sql_raises_error, +} \ No newline at end of file diff --git a/packages/cli/tests/bindings-test/src/kv_test.py b/packages/cli/tests/bindings-test/src/kv_test.py index d37d4f2..8b3a252 100644 --- a/packages/cli/tests/bindings-test/src/kv_test.py +++ b/packages/cli/tests/bindings-test/src/kv_test.py @@ -1,17 +1,264 @@ +import json + + async def _cleanup_kv(kv): - result = await kv.list() - for item in result.keys: - await kv.delete(item.name) + cursor = None + while True: + options = {"prefix": "_test:", "limit": 1000} + if cursor: + options["cursor"] = cursor + result = await kv.list(options) + for key_entry in result["keys"]: + await kv.delete(key_entry["name"]) + if result["list_complete"]: + break + cursor = result.get("cursor") + + +async def test_put_and_get_text(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:put_get_text" + value = "hello from KV" + await kv.put(key, value) + result = await kv.get(key) + assert result == value, f"expected {value!r}, got {result!r}" + + +async def test_get_nonexistent(env): + kv = env.KV + await _cleanup_kv(kv) + result = await kv.get("_test:does_not_exist_12345") + assert result is None, f"expected None, got {result!r}" + + +async def test_put_and_get_json(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:put_get_json" + payload = {"message": "hello", "numbers": [1, 2, 3]} + await kv.put(key, json.dumps(payload)) + result = await kv.get(key, "json") + assert isinstance(result, dict), f"expected dict, got {type(result)}: {result!r}" + assert result == payload, f"json mismatch: {result!r}" + + +async def test_put_overwrite(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:overwrite" + await kv.put(key, "version1") + first = await kv.get(key) + await kv.put(key, "version2") + second = await kv.get(key) + assert first == "version1" + assert second == "version2" + + +async def test_put_empty_value(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:empty_value" + await kv.put(key, "") + result = await kv.get(key) + assert result == "", f"expected empty string, got {result!r}" + + +async def test_delete(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:delete" + await kv.put(key, "to be deleted") + assert await kv.get(key) == "to be deleted" + await kv.delete(key) + result = await kv.get(key) + assert result is None, f"expected None after delete, got {result!r}" + + +async def test_delete_nonexistent(env): + kv = env.KV + await _cleanup_kv(kv) + await kv.delete("_test:does_not_exist_67890") + + +async def test_put_with_metadata(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:metadata" + metadata = {"author": "test-suite", "version": "1.0"} + await kv.put(key, "metadata test", {"metadata": metadata}) + result = await kv.getWithMetadata(key) + assert result["value"] == "metadata test" + assert result["metadata"] is not None, "expected metadata" + assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" + + +async def test_get_with_metadata_nonexistent(env): + kv = env.KV + await _cleanup_kv(kv) + result = await kv.getWithMetadata("_test:does_not_exist_meta") + assert result["value"] is None, f"expected None value, got {result['value']!r}" + assert result["metadata"] is None, ( + f"expected None metadata, got {result['metadata']!r}" + ) + + +async def test_put_with_expiration_ttl(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:expiration_ttl" + await kv.put(key, "expires soon", {"expirationTtl": 60}) + result = await kv.get(key) + assert result == "expires soon", f"value mismatch: {result!r}" + listed = await kv.list({"prefix": key}) + matching = [k for k in listed["keys"] if k["name"] == key] + assert len(matching) == 1, "key not found in list" + assert matching[0].get("expiration") is not None, "expected expiration to be set" + + +async def test_list_basic(env): + kv = env.KV + await _cleanup_kv(kv) + for i in range(3): + await kv.put(f"_test:list_basic:{i}", f"val-{i}") + result = await kv.list({"prefix": "_test:list_basic:"}) + keys = result["keys"] + names = [k["name"] for k in keys] + assert len(keys) >= 3, f"expected >= 3 keys, got {len(keys)}" + assert result["list_complete"] + for i in range(3): + assert f"_test:list_basic:{i}" in names, f"missing key {i}" + + +async def test_list_with_prefix(env): + kv = env.KV + await _cleanup_kv(kv) + await kv.put("_test:prefix_a:1", "a1") + await kv.put("_test:prefix_a:2", "a2") + await kv.put("_test:prefix_b:1", "b1") + result = await kv.list({"prefix": "_test:prefix_a:"}) + names = [k["name"] for k in result["keys"]] + assert len(names) == 2, f"expected 2 keys, got {len(names)}" + assert all(n.startswith("_test:prefix_a:") for n in names), ( + f"prefix filter failed: {names!r}" + ) + + +async def test_list_with_limit_and_cursor(env): + kv = env.KV + await _cleanup_kv(kv) + prefix = "_test:paginate:" + for i in range(5): + await kv.put(f"{prefix}{i:03d}", f"val-{i}") + page1 = await kv.list({"prefix": prefix, "limit": 2}) + assert len(page1["keys"]) == 2, f"first page: expected 2, got {len(page1['keys'])}" + assert not page1["list_complete"], "expected list_complete=False" + assert page1.get("cursor") is not None, "expected cursor on first page" + page2 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) + assert len(page2["keys"]) == 2, f"second page: expected 2, got {len(page2['keys'])}" + page3 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) + assert len(page3["keys"]) == 1, f"third page: expected 1, got {len(page3['keys'])}" + assert page3["list_complete"], "expected list_complete=True on last page" + + +async def test_list_empty_prefix(env): + kv = env.KV + await _cleanup_kv(kv) + result = await kv.list({"prefix": "_test:nonexistent_prefix_xyz:"}) + assert len(result["keys"]) == 0, f"expected 0 keys, got {len(result['keys'])}" + assert result["list_complete"] + + +async def test_list_with_metadata(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:list_meta" + metadata = {"tag": "listed"} + await kv.put(key, "has metadata", {"metadata": metadata}) + result = await kv.list({"prefix": key}) + matching = [k for k in result["keys"] if k["name"] == key] + assert len(matching) == 1, f"expected one key, got {len(matching)}" + assert matching[0].get("metadata") is not None, "expected metadata in list result" + assert matching[0]["metadata"] == metadata, ( + f"metadata mismatch: {matching[0]['metadata']!r}" + ) + + +async def test_get_with_metadata_has_metadata(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:gwm_meta" + metadata = {"env": "test", "version": 2} + await kv.put(key, "value with meta", {"metadata": metadata}) + result = await kv.getWithMetadata(key) + assert result["value"] == "value with meta" + assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" + + +async def test_get_type_as_options_dict(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:get_type_opts" + payload = {"x": "hello"} + await kv.put(key, json.dumps(payload)) + result = await kv.get(key, {"type": "json"}) + assert isinstance(result, dict), f"expected dict, got {type(result)}: {result!r}" + assert result["x"] == "hello" + + +async def test_get_arraybuffer(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:get_ab" + await kv.put(key, "binary test data") + result = await kv.get(key, "arrayBuffer") + assert result is not None + assert isinstance(result, memoryview), f"expected memoryview, got {type(result)}" + assert len(result) > 0 + + +async def test_get_multiple_keys(env): + kv = env.KV + await _cleanup_kv(kv) + await kv.put("_test:multi:a", "val-a") + await kv.put("_test:multi:b", "val-b") + result = await kv.get(["_test:multi:a", "_test:multi:b", "_test:multi:nonexistent"]) + assert isinstance(result, dict), f"expected dict from multi-get, got {type(result)}" + assert result.get("_test:multi:a") == "val-a" + assert result.get("_test:multi:b") == "val-b" + assert result.get("_test:multi:nonexistent") is None -async def test_put_and_get(env): +async def test_get_multiple_keys_json(env): kv = env.KV await _cleanup_kv(kv) - await kv.put("hello", "world") - value = await kv.get("hello") - assert value == "world", f"Expected 'world', got {value!r}" + await kv.put("_test:multi_json:a", json.dumps({"val": "a"})) + await kv.put("_test:multi_json:b", json.dumps({"val": "b"})) + result = await kv.get(["_test:multi_json:a", "_test:multi_json:b"], "json") + assert isinstance(result, dict), f"expected dict, got {type(result)}" + assert result["_test:multi_json:a"]["val"] == "a" + assert result["_test:multi_json:b"]["val"] == "b" KV_TESTS = { - "put_and_get": test_put_and_get, + "put_and_get_text": test_put_and_get_text, + "get_nonexistent": test_get_nonexistent, + "put_and_get_json": test_put_and_get_json, + "put_overwrite": test_put_overwrite, + "put_empty_value": test_put_empty_value, + "delete": test_delete, + "delete_nonexistent": test_delete_nonexistent, + "put_with_metadata": test_put_with_metadata, + "get_with_metadata_nonexistent": test_get_with_metadata_nonexistent, + "put_with_expiration_ttl": test_put_with_expiration_ttl, + "list_basic": test_list_basic, + "list_with_prefix": test_list_with_prefix, + "list_with_limit_and_cursor": test_list_with_limit_and_cursor, + "list_empty_prefix": test_list_empty_prefix, + "list_with_metadata": test_list_with_metadata, + "get_with_metadata_has_metadata": test_get_with_metadata_has_metadata, + "get_type_as_options_dict": test_get_type_as_options_dict, + "get_arraybuffer": test_get_arraybuffer, + "get_multiple_keys": test_get_multiple_keys, + "get_multiple_keys_json": test_get_multiple_keys_json, } diff --git a/packages/cli/tests/bindings-test/src/r2_test.py b/packages/cli/tests/bindings-test/src/r2_test.py new file mode 100644 index 0000000..26dde66 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/r2_test.py @@ -0,0 +1,319 @@ +import json + + +async def _cleanup_r2(bucket): + cursor = None + while True: + options = {"prefix": "_test/", "limit": 1000} + if cursor: + options["cursor"] = cursor + result = await bucket.list(options) + keys = [obj.key for obj in result.objects] + if keys: + await bucket.delete(keys) + if not result.truncated: + break + cursor = result.cursor + + +async def test_put_and_get_text(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/put_get_text" + value = "Hello from R2 binding!" + obj = await bucket.put(key, value) + assert obj is not None, "put returned None" + assert obj["key"] == key + assert obj["size"] == len(value) + body = await bucket.get(key) + assert body is not None, "get returned None" + text = await body.text() + assert text == value, f"text mismatch: {text!r}" + assert body["bodyUsed"] is True + + +async def test_put_and_get_json(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/put_get_json" + payload = {"message": "hello", "numbers": [1, 2, 3]} + await bucket.put(key, json.dumps(payload), { + "httpMetadata": {"contentType": "application/json"}, + }) + body = await bucket.get(key) + assert body is not None, "get returned None" + parsed = await body.json() + assert parsed == payload, f"json mismatch: {parsed!r}" + + +async def test_put_with_http_metadata(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/http_meta" + await bucket.put(key, "metadata test", { + "httpMetadata": { + "contentType": "text/plain", + "contentLanguage": "en-US", + "contentDisposition": "inline", + "cacheControl": "max-age=3600", + }, + }) + head = await bucket.head(key) + assert head is not None, "head returned None" + meta = head["httpMetadata"] + assert meta["contentType"] == "text/plain" + assert meta["contentLanguage"] == "en-US" + assert meta["contentDisposition"] == "inline" + assert meta["cacheControl"] == "max-age=3600" + + +async def test_put_with_custom_metadata(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/custom_meta" + custom = {"author": "test-suite", "version": "1.0"} + await bucket.put(key, "custom metadata test", {"customMetadata": custom}) + head = await bucket.head(key) + assert head is not None, "head returned None" + assert head["customMetadata"] == custom, f"custom metadata mismatch: {head['customMetadata']!r}" + + +async def test_head_object(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/head_obj" + content = "head test content" + await bucket.put(key, content) + head = await bucket.head(key) + assert head is not None, "head returned None" + assert head["key"] == key + assert head["size"] == len(content) + assert head["etag"] is not None + assert head["httpEtag"] is not None + assert head["version"] is not None + + +async def test_get_nonexistent(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + result = await bucket.get("_test/does_not_exist_12345") + assert result is None, f"expected None, got {result!r}" + + +async def test_head_nonexistent(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + result = await bucket.head("_test/does_not_exist_12345") + assert result is None, f"expected None, got {result!r}" + + +async def test_delete_single(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/delete_single" + await bucket.put(key, "to be deleted") + assert (await bucket.head(key)) is not None, "put failed" + await bucket.delete(key) + result = await bucket.head(key) + assert result is None, "object still exists after delete" + + +async def test_delete_multiple(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + keys = ["_test/del_multi_1", "_test/del_multi_2", "_test/del_multi_3"] + for key in keys: + await bucket.put(key, f"content for {key}") + await bucket.delete(keys) + for key in keys: + result = await bucket.head(key) + assert result is None, f"{key} still exists after batch delete" + + +async def test_list_basic(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + for i in range(3): + await bucket.put(f"_test/list_basic/{i}", f"val-{i}") + result = await bucket.list({"prefix": "_test/list_basic/"}) + objects = result["objects"] + keys = [obj.key for obj in objects] + assert len(objects) >= 3, f"expected >= 3 objects, got {len(objects)}" + for i in range(3): + assert f"_test/list_basic/{i}" in keys, f"missing key {i}" + + +async def test_list_with_prefix(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + await bucket.put("_test/prefix_a/1", "a1") + await bucket.put("_test/prefix_a/2", "a2") + await bucket.put("_test/prefix_b/1", "b1") + result = await bucket.list({"prefix": "_test/prefix_a/"}) + keys = [obj.key for obj in result["objects"]] + assert len(keys) == 2, f"expected 2 objects, got {len(keys)}" + assert all(k.startswith("_test/prefix_a/") for k in keys), f"prefix filter failed: {keys!r}" + + +async def test_list_with_limit_and_cursor(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + prefix = "_test/paginate/" + for i in range(5): + await bucket.put(f"{prefix}{i:03d}", f"val-{i}") + page1 = await bucket.list({"prefix": prefix, "limit": 2}) + assert len(page1["objects"]) == 2, f"first page: expected 2, got {len(page1['objects'])}" + assert page1["truncated"], "expected truncated=True" + assert page1["cursor"] is not None, "expected cursor" + page2 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) + assert len(page2["objects"]) == 2, f"second page: expected 2, got {len(page2['objects'])}" + page3 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) + assert len(page3["objects"]) == 1, f"third page: expected 1, got {len(page3['objects'])}" + assert not page3["truncated"], "expected truncated=False on last page" + + +async def test_list_with_delimiter(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + await bucket.put("_test/delim/dir1/file1", "f1") + await bucket.put("_test/delim/dir1/file2", "f2") + await bucket.put("_test/delim/dir2/file1", "f1") + await bucket.put("_test/delim/root_file", "rf") + result = await bucket.list({"prefix": "_test/delim/", "delimiter": "/"}) + object_keys = [obj.key for obj in result["objects"]] + prefixes = result["delimitedPrefixes"] + assert "_test/delim/root_file" in object_keys, f"missing root file: {object_keys!r}" + assert "_test/delim/dir1/" in prefixes, f"missing dir1 prefix: {prefixes!r}" + assert "_test/delim/dir2/" in prefixes, f"missing dir2 prefix: {prefixes!r}" + + +async def test_overwrite_object(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/overwrite" + await bucket.put(key, "version1") + first = await (await bucket.get(key)).text() + await bucket.put(key, "version2") + second = await (await bucket.get(key)).text() + assert first == "version1" + assert second == "version2" + + +async def test_put_empty_body(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/empty_body" + obj = await bucket.put(key, None) + assert obj is not None, "put returned None" + assert obj.size == 0, f"expected size 0, got {obj.size}" + body = await bucket.get(key) + assert body is not None, "get returned None" + text = await body.text() + assert text == "", f"expected empty string, got {text!r}" + + +async def test_get_range_offset_length(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/range_test" + content = "0123456789ABCDEF" + await bucket.put(key, content) + body = await bucket.get(key, {"range": {"offset": 4, "length": 6}}) + assert body is not None, "get returned None" + text = await body.text() + assert text == "456789", f"range mismatch: {text!r}" + + +async def test_get_range_suffix(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/range_suffix" + content = "0123456789ABCDEF" + await bucket.put(key, content) + body = await bucket.get(key, {"range": {"suffix": 4}}) + assert body is not None, "get returned None" + text = await body.text() + assert text == "CDEF", f"suffix mismatch: {text!r}" + + +async def test_r2object_properties(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/props" + content = "properties test" + obj = await bucket.put(key, content, { + "httpMetadata": {"contentType": "text/plain"}, + "customMetadata": {"foo": "bar"}, + }) + assert obj["key"] == key + assert obj["size"] == len(content) + assert isinstance(obj["version"], str) and obj["version"] + assert isinstance(obj["etag"], str) and obj["etag"] + assert isinstance(obj["httpEtag"], str) and obj["httpEtag"].startswith('"') + assert obj["uploaded"] is not None + assert obj["storageClass"] in ("Standard", "InfrequentAccess", "") + head = await bucket.head(key) + assert head["httpMetadata"]["contentType"] == "text/plain" + assert head["customMetadata"] == {"foo": "bar"} + + +async def test_multipart_upload(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/multipart" + upload = await bucket.createMultipartUpload(key, { + "customMetadata": {"uploadType": "multipart_test"}, + }) + assert upload["key"] == key + assert isinstance(upload["uploadId"], str) and upload["uploadId"] + five_mb = 5 * 1024 * 1024 + part1_data = "A" * five_mb + part2_data = "B" * 512 + p1 = await upload.uploadPart(1, part1_data) + p2 = await upload.uploadPart(2, part2_data) + assert p1["partNumber"] == 1 + assert p2["partNumber"] == 2 + assert isinstance(p1["etag"], str) and p1["etag"] + obj = await upload.complete([p1, p2]) + assert obj["key"] == key + assert obj["size"] == len(part1_data) + len(part2_data) + body = await bucket.get(key) + assert body is not None + text = await body.text() + assert text == part1_data + part2_data, "multipart content mismatch" + + +async def test_multipart_abort(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/multipart_abort" + upload = await bucket.createMultipartUpload(key) + assert upload["key"] == key + await upload.uploadPart(1, "data to be aborted") + await upload.abort() + result = await bucket.head(key) + assert result is None, "object should not exist after abort" + + +R2_TESTS = { + "put_and_get_text": test_put_and_get_text, + "put_and_get_json": test_put_and_get_json, + "put_with_http_metadata": test_put_with_http_metadata, + "put_with_custom_metadata": test_put_with_custom_metadata, + "head_object": test_head_object, + "get_nonexistent": test_get_nonexistent, + "head_nonexistent": test_head_nonexistent, + "delete_single": test_delete_single, + "delete_multiple": test_delete_multiple, + "list_basic": test_list_basic, + "list_with_prefix": test_list_with_prefix, + "list_with_limit_and_cursor": test_list_with_limit_and_cursor, + "list_with_delimiter": test_list_with_delimiter, + "overwrite_object": test_overwrite_object, + "put_empty_body": test_put_empty_body, + "get_range_offset_length": test_get_range_offset_length, + "get_range_suffix": test_get_range_suffix, + "r2object_properties": test_r2object_properties, + "multipart_upload": test_multipart_upload, + "multipart_abort": test_multipart_abort, +} \ No newline at end of file diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index b83d3ae..3edce73 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -1,10 +1,14 @@ import traceback +from d1_test import D1_TESTS from kv_test import KV_TESTS +from r2_test import R2_TESTS from workers import Response, WorkerEntrypoint ALL_TESTS = { "kv": KV_TESTS, + "r2": R2_TESTS, + "d1": D1_TESTS, } diff --git a/packages/cli/tests/bindings-test/wrangler.jsonc b/packages/cli/tests/bindings-test/wrangler.jsonc index 00e0910..8c51d93 100644 --- a/packages/cli/tests/bindings-test/wrangler.jsonc +++ b/packages/cli/tests/bindings-test/wrangler.jsonc @@ -5,5 +5,15 @@ "compatibility_flags": ["python_workers"], "kv_namespaces": [ { "binding": "KV", "id": "test-kv" } + ], + "r2_buckets": [ + { "binding": "BUCKET", "bucket_name": "test-bucket" } + ], + "d1_databases": [ + { + "binding": "DB", + "database_id": "00000000-0000-0000-0000-000000000000", + "database_name": "test-db" + } ] } diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 814956e..1c07aab 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -66,7 +66,7 @@ def _wait_for_ready(process: subprocess.Popen[str], base_url: str) -> None: @pytest.fixture(scope="module") -def dev_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[str]: +def dev_server(tmp_path_factory: Any) -> Generator[str]: """Start a pywrangler dev server on a free port and yield its base URL.""" tmp_path = tmp_path_factory.mktemp("bindings_test") target = tmp_path / "bindings-test" @@ -150,6 +150,75 @@ def binding_suite(suite: str, tests: list[str]) -> type: TestKV = binding_suite( "kv", [ - "put_and_get", + "put_and_get_text", + "get_nonexistent", + "put_and_get_json", + "put_overwrite", + "put_empty_value", + "delete", + "delete_nonexistent", + "put_with_metadata", + "get_with_metadata_nonexistent", + "put_with_expiration_ttl", + "list_basic", + "list_with_prefix", + "list_with_limit_and_cursor", + "list_empty_prefix", + "list_with_metadata", + "get_with_metadata_has_metadata", + "get_type_as_options_dict", + "get_arraybuffer", + "get_multiple_keys", + "get_multiple_keys_json", + ], +) + +TestR2 = binding_suite( + "r2", + [ + "put_and_get_text", + "put_and_get_json", + "put_with_http_metadata", + "put_with_custom_metadata", + "head_object", + "get_nonexistent", + "head_nonexistent", + "delete_single", + "delete_multiple", + "list_basic", + "list_with_prefix", + "list_with_limit_and_cursor", + "list_with_delimiter", + "overwrite_object", + "put_empty_body", + "get_range_offset_length", + "get_range_suffix", + "r2object_properties", + "multipart_upload", + "multipart_abort", + ], +) + +TestD1 = binding_suite( + "d1", + [ + "insert_and_select_via_run", + "all_returns_results", + "first_returns_single_row", + "first_with_column_name", + "first_on_empty_result", + "raw_returns_arrays", + "raw_with_column_names", + "bind_types", + "exec_create_and_query", + "exec_multiple_statements", + "batch_multiple_inserts", + "run_metadata_fields", + "update_row", + "delete_row", + "session_prepare_and_query", + "session_bookmark", + "session_batch", + "invalid_sql_raises_error", ], ) diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 95f4778..ad96de0 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -902,6 +902,9 @@ async def text(self) -> str: def _python_from_rpc_default_converter(value, convert, cache): + if value is jsnull: + return None + if not hasattr(value, "constructor"): # Assume that the object doesn't need conversion as it's not a JS object. return value @@ -933,6 +936,34 @@ def _python_from_rpc_default_converter(value, convert, cache): return value +class JsDict(dict): + """ + Python dictionary that allows attribute access to keys. + + This is used to convert JS objects to Python dictionaries while maintaining + the ability to access keys as attributes. + """ + + def __getattr__(self, name): + try: + return self[name] + except KeyError: + raise AttributeError(name) from None + + +def _normalize_result(obj): + """ + Recursively converts JS objects to Python objects. + """ + if obj is jsnull: + return None + if isinstance(obj, dict): + return JsDict({k: _normalize_result(v) for k, v in obj.items()}) + if isinstance(obj, list): + return [_normalize_result(v) for v in obj] + return obj + + def python_from_rpc(obj: "JsProxy"): """ Converts JS objects like Response, Request, Blob, etc. to equivalent Python objects defined in @@ -942,6 +973,9 @@ def python_from_rpc(obj: "JsProxy"): it does not support serializing all JS object types. """ + if obj is jsnull: + return None + if not hasattr(obj, "constructor"): return obj @@ -952,10 +986,13 @@ def python_from_rpc(obj: "JsProxy"): result = obj.to_py(default_converter=_python_from_rpc_default_converter) - return result + return _normalize_result(result) def _raise_on_disabled_type(value): + if isinstance(value, _RPCWrapper): + return + if _is_js_instance(value, "RegExp"): raise TypeError(f"{value.constructor.name} cannot be sent over RPC.") @@ -979,6 +1016,9 @@ def _python_to_rpc_default_converter(obj, convert, cache): if obj is None: return obj + if isinstance(obj, _RPCWrapper): + return obj._binding + if hasattr(obj, "js_object"): return obj.js_object @@ -1003,37 +1043,48 @@ def python_to_rpc(value) -> JsProxy: it does not support serializing all Python object types. """ + if isinstance(value, _RPCWrapper): + return value._binding + # `to_js` won't always call the default_converter, for example when a list of tuples is passed _raise_on_disabled_type(value) result = to_js( value, default_converter=_python_to_rpc_default_converter, - dict_converter=js.Map.new, + dict_converter=Object.fromEntries, ) return result -class _FetcherWrapper: +class _RPCWrapper: def __init__(self, binding): self._binding = binding + def _convert_result(self, result): + converted = python_from_rpc(result) + if isinstance(converted, JsProxy): + return self.__class__(converted) + return converted + def _getattr_helper(self, name): attr = getattr(self._binding, name) if not callable(attr): - return attr + return self._convert_result(attr) - # Not using `@functools.wraps(attr)` here because `attr` is a JS proxy. - async def wrapper(*args, **kwargs): + def wrapper(*args, **kwargs): js_args = [python_to_rpc(arg) for arg in args] js_kwargs = {k: python_to_rpc(v) for k, v in kwargs.items()} result = attr(*js_args, **js_kwargs) if hasattr(result, "then") and callable(result.then): - return python_from_rpc(await result) - else: - return python_from_rpc(result) + + async def await_and_convert(): + return self._convert_result(await result) + + return await_and_convert() + return self._convert_result(result) return wrapper @@ -1042,10 +1093,25 @@ def __getattr__(self, name): setattr(self, name, result) return result + def __getitem__(self, key): + return self._convert_result(getattr(self._binding, key)) + + +class _FetcherWrapper(_RPCWrapper): def fetch(self, *args, **kwargs): return fetch(*args, fetcher=self._binding.fetch, **kwargs) +class _D1DatabaseWrapper(_RPCWrapper): + def bind(self, *args): + """ + D1.bind() requires extra conversion (None => jsnull). + """ + js_args = [jsnull if a is None else python_to_rpc(a) for a in args] + result = self._binding.bind(*js_args) + return self._convert_result(result) + + class _DurableObjectNamespaceWrapper: def __init__(self, binding): self._binding = binding @@ -1159,7 +1225,15 @@ def _getattr_helper(self, name): if _is_js_instance(binding, "WorkflowImpl"): return _WorkflowBindingWrapper(binding) - # TODO: Implement APIs for bindings. + if _is_js_instance(binding, "KvNamespace"): + return _RPCWrapper(binding) + + if _is_js_instance(binding, "R2Bucket"): + return _RPCWrapper(binding) + + if _is_js_instance(binding, "D1Database"): + return _D1DatabaseWrapper(binding) + return binding def __getattr__(self, name): From 384a7fb5ce9c53f40ce8ec56b2e9be67fcc0c1a0 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 26 May 2026 18:57:47 +0900 Subject: [PATCH 02/12] chore: lint --- .../cli/tests/bindings-test/src/d1_test.py | 294 ++++++++++++------ .../cli/tests/bindings-test/src/r2_test.py | 71 +++-- 2 files changed, 240 insertions(+), 125 deletions(-) diff --git a/packages/cli/tests/bindings-test/src/d1_test.py b/packages/cli/tests/bindings-test/src/d1_test.py index 520639a..8a17a1b 100644 --- a/packages/cli/tests/bindings-test/src/d1_test.py +++ b/packages/cli/tests/bindings-test/src/d1_test.py @@ -6,7 +6,13 @@ async def _cleanup_d1(db): - for table in [TEST_TABLE, TEST_TABLE_TYPES, TEST_TABLE_BATCH, EXEC_TABLE, EXEC_MULTI_TABLE]: + for table in [ + TEST_TABLE, + TEST_TABLE_TYPES, + TEST_TABLE_BATCH, + EXEC_TABLE, + EXEC_MULTI_TABLE, + ]: try: await db.exec(f"DROP TABLE IF EXISTS {table}") except Exception: @@ -32,17 +38,21 @@ async def test_insert_and_select_via_run(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - insert_result = await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("run_test", "hello").run() + insert_result = ( + await db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("run_test", "hello") + .run() + ) assert insert_result["success"] is True, f"insert failed: {insert_result!r}" meta = insert_result["meta"] assert meta["changes"] >= 1 assert meta["last_row_id"] > 0 row_id = meta["last_row_id"] - select_result = await db.prepare( - f"SELECT id, name, value FROM {TEST_TABLE} WHERE id = ?" - ).bind(row_id).run() + select_result = ( + await db.prepare(f"SELECT id, name, value FROM {TEST_TABLE} WHERE id = ?") + .bind(row_id) + .run() + ) rows = select_result["results"] assert len(rows) == 1, f"expected 1 row, got {len(rows)}" assert rows[0]["name"] == "run_test" @@ -53,12 +63,16 @@ async def test_all_returns_results(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("all_test", "v1").run() - result = await db.prepare( - f"SELECT name, value FROM {TEST_TABLE} WHERE name = ?" - ).bind("all_test").all() + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("all_test", "v1") + .run() + ) + result = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ?") + .bind("all_test") + .all() + ) assert result["success"] is True rows = result["results"] assert len(rows) >= 1, f"expected >= 1 row, got {len(rows)}" @@ -69,12 +83,16 @@ async def test_first_returns_single_row(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("first_test", "fv").run() - row = await db.prepare( - f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" - ).bind("first_test").first() + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("first_test", "fv") + .run() + ) + row = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("first_test") + .first() + ) assert row is not None, "first() returned None" assert isinstance(row, dict), f"expected dict, got {type(row)}: {row}" assert row["name"] == "first_test" @@ -85,12 +103,16 @@ async def test_first_with_column_name(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("first_col", "col_val").run() - value = await db.prepare( - f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" - ).bind("first_col").first("value") + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("first_col", "col_val") + .run() + ) + value = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("first_col") + .first("value") + ) assert value == "col_val", f"expected 'col_val', got {value!r}" @@ -98,9 +120,11 @@ async def test_first_on_empty_result(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - row = await db.prepare( - f"SELECT * FROM {TEST_TABLE} WHERE name = ?" - ).bind("__nonexistent__xyz__").first() + row = ( + await db.prepare(f"SELECT * FROM {TEST_TABLE} WHERE name = ?") + .bind("__nonexistent__xyz__") + .first() + ) assert row is None, f"expected None, got {row!r}" @@ -108,12 +132,16 @@ async def test_raw_returns_arrays(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("raw_test", "rv").run() - rows = await db.prepare( - f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" - ).bind("raw_test").raw() + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("raw_test", "rv") + .run() + ) + rows = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("raw_test") + .raw() + ) assert isinstance(rows, list), f"expected list, got {type(rows)}" assert len(rows) == 1, f"expected 1 row, got {len(rows)}" assert rows[0] == ["raw_test", "rv"], f"row mismatch: {rows!r}" @@ -123,12 +151,16 @@ async def test_raw_with_column_names(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("raw_cols", "rc").run() - rows = await db.prepare( - f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1" - ).bind("raw_cols").raw({"columnNames": True}) + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("raw_cols", "rc") + .run() + ) + rows = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("raw_cols") + .raw({"columnNames": True}) + ) assert len(rows) == 2, f"expected header + data, got {len(rows)} rows" assert rows[0] == ["name", "value"], f"header mismatch: {rows[0]!r}" assert rows[1] == ["raw_cols", "rc"], f"row mismatch: {rows[1]!r}" @@ -139,39 +171,63 @@ async def test_bind_types(env): await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" - ).bind(1, None, None, None).run() - await db.prepare( - f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" - ).bind(2, "hello, D1!", 3.14, 42).run() - await db.prepare( - f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)" - ).bind(3, True).run() - await db.prepare( - f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)" - ).bind(4, False).run() - - row1 = await db.prepare( - f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" - ).bind(1).first() + await ( + db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" + ) + .bind(1, None, None, None) + .run() + ) + await ( + db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" + ) + .bind(2, "hello, D1!", 3.14, 42) + .run() + ) + await ( + db.prepare(f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)") + .bind(3, True) + .run() + ) + await ( + db.prepare(f"INSERT INTO {TEST_TABLE_TYPES} (id, intval) VALUES (?, ?)") + .bind(4, False) + .run() + ) + + row1 = ( + await db.prepare( + f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ) + .bind(1) + .first() + ) assert row1["txt"] is None, f"expected None, got {row1['txt']!r}" assert row1["num"] is None, f"expected None, got {row1['num']!r}" assert row1["intval"] is None, f"expected None, got {row1['intval']!r}" - row2 = await db.prepare( - f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" - ).bind(2).first() + row2 = ( + await db.prepare( + f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ) + .bind(2) + .first() + ) assert row2["txt"] == "hello, D1!" assert abs(row2["num"] - 3.14) < 0.001 assert row2["intval"] == 42 - row3 = await db.prepare( - f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?" - ).bind(3).first() - row4 = await db.prepare( - f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?" - ).bind(4).first() + row3 = ( + await db.prepare(f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?") + .bind(3) + .first() + ) + row4 = ( + await db.prepare(f"SELECT intval FROM {TEST_TABLE_TYPES} WHERE id = ?") + .bind(4) + .first() + ) assert row3["intval"] == 1, f"True should be 1, got {row3['intval']}" assert row4["intval"] == 0, f"False should be 0, got {row4['intval']}" @@ -204,9 +260,15 @@ async def test_batch_multiple_inserts(env): await _cleanup_d1(db) await _ensure_tables(db) statements = [ - db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(1, "batch_a"), - db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(2, "batch_b"), - db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(3, "batch_c"), + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind( + 1, "batch_a" + ), + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind( + 2, "batch_b" + ), + db.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind( + 3, "batch_c" + ), ] results = await db.batch(statements) assert results is not None, "batch returned None" @@ -222,12 +284,22 @@ async def test_run_metadata_fields(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - result = await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("meta_test", "mv").run() + result = ( + await db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("meta_test", "mv") + .run() + ) assert result["success"] is True meta = result["meta"] - for key in ["duration", "changes", "last_row_id", "changed_db", "rows_read", "rows_written", "size_after"]: + for key in [ + "duration", + "changes", + "last_row_id", + "changed_db", + "rows_read", + "rows_written", + "size_after", + ]: assert key in meta, f"missing {key!r} in meta: {meta!r}" assert meta["changes"] >= 1 assert meta["changed_db"] is True @@ -237,17 +309,23 @@ async def test_update_row(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - insert_result = await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("update_me", "old_value").run() + insert_result = ( + await db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("update_me", "old_value") + .run() + ) row_id = insert_result["meta"]["last_row_id"] - update_result = await db.prepare( - f"UPDATE {TEST_TABLE} SET value = ? WHERE id = ?" - ).bind("new_value", row_id).run() + update_result = ( + await db.prepare(f"UPDATE {TEST_TABLE} SET value = ? WHERE id = ?") + .bind("new_value", row_id) + .run() + ) assert update_result["meta"]["changes"] == 1 - row = await db.prepare( - f"SELECT value FROM {TEST_TABLE} WHERE id = ?" - ).bind(row_id).first() + row = ( + await db.prepare(f"SELECT value FROM {TEST_TABLE} WHERE id = ?") + .bind(row_id) + .first() + ) assert row["value"] == "new_value", f"expected 'new_value', got {row['value']!r}" @@ -255,17 +333,21 @@ async def test_delete_row(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - insert_result = await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("delete_me", "gone").run() + insert_result = ( + await db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("delete_me", "gone") + .run() + ) row_id = insert_result["meta"]["last_row_id"] - delete_result = await db.prepare( - f"DELETE FROM {TEST_TABLE} WHERE id = ?" - ).bind(row_id).run() + delete_result = ( + await db.prepare(f"DELETE FROM {TEST_TABLE} WHERE id = ?").bind(row_id).run() + ) assert delete_result["meta"]["changes"] == 1 - row = await db.prepare( - f"SELECT * FROM {TEST_TABLE} WHERE id = ?" - ).bind(row_id).first() + row = ( + await db.prepare(f"SELECT * FROM {TEST_TABLE} WHERE id = ?") + .bind(row_id) + .first() + ) assert row is None, f"row should be deleted, got {row!r}" @@ -273,14 +355,14 @@ async def test_session_prepare_and_query(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) - await db.prepare( - f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)" - ).bind("session_test", "sv").run() + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("session_test", "sv") + .run() + ) session = db.withSession() assert session is not None, "withSession() returned None" - result = await session.prepare( - f"SELECT COUNT(*) as cnt FROM {TEST_TABLE}" - ).all() + result = await session.prepare(f"SELECT COUNT(*) as cnt FROM {TEST_TABLE}").all() assert result["success"] is True assert result["results"][0]["cnt"] >= 1 @@ -291,11 +373,15 @@ async def test_session_bookmark(env): await _ensure_tables(db) session = db.withSession() bookmark_before = session.getBookmark() - assert bookmark_before is None, f"expected None before query, got {bookmark_before!r}" - await session.prepare(f"SELECT 1").all() + assert bookmark_before is None, ( + f"expected None before query, got {bookmark_before!r}" + ) + await session.prepare("SELECT 1").all() bookmark_after = session.getBookmark() assert bookmark_after is not None, "expected bookmark after query, got None" - assert isinstance(bookmark_after, str), f"expected string, got {type(bookmark_after)}" + assert isinstance(bookmark_after, str), ( + f"expected string, got {type(bookmark_after)}" + ) assert len(bookmark_after) > 0, "bookmark should be non-empty" @@ -305,8 +391,12 @@ async def test_session_batch(env): await _ensure_tables(db) session = db.withSession() statements = [ - session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(1, "sa"), - session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind(2, "sb"), + session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind( + 1, "sa" + ), + session.prepare(f"INSERT INTO {TEST_TABLE_BATCH} (id, val) VALUES (?, ?)").bind( + 2, "sb" + ), ] results = await session.batch(statements) assert results is not None, "session batch returned None" @@ -348,4 +438,4 @@ async def test_invalid_sql_raises_error(env): "session_bookmark": test_session_bookmark, "session_batch": test_session_batch, "invalid_sql_raises_error": test_invalid_sql_raises_error, -} \ No newline at end of file +} diff --git a/packages/cli/tests/bindings-test/src/r2_test.py b/packages/cli/tests/bindings-test/src/r2_test.py index 26dde66..fae90c9 100644 --- a/packages/cli/tests/bindings-test/src/r2_test.py +++ b/packages/cli/tests/bindings-test/src/r2_test.py @@ -37,9 +37,13 @@ async def test_put_and_get_json(env): await _cleanup_r2(bucket) key = "_test/put_get_json" payload = {"message": "hello", "numbers": [1, 2, 3]} - await bucket.put(key, json.dumps(payload), { - "httpMetadata": {"contentType": "application/json"}, - }) + await bucket.put( + key, + json.dumps(payload), + { + "httpMetadata": {"contentType": "application/json"}, + }, + ) body = await bucket.get(key) assert body is not None, "get returned None" parsed = await body.json() @@ -50,14 +54,18 @@ async def test_put_with_http_metadata(env): bucket = env.BUCKET await _cleanup_r2(bucket) key = "_test/http_meta" - await bucket.put(key, "metadata test", { - "httpMetadata": { - "contentType": "text/plain", - "contentLanguage": "en-US", - "contentDisposition": "inline", - "cacheControl": "max-age=3600", + await bucket.put( + key, + "metadata test", + { + "httpMetadata": { + "contentType": "text/plain", + "contentLanguage": "en-US", + "contentDisposition": "inline", + "cacheControl": "max-age=3600", + }, }, - }) + ) head = await bucket.head(key) assert head is not None, "head returned None" meta = head["httpMetadata"] @@ -75,7 +83,9 @@ async def test_put_with_custom_metadata(env): await bucket.put(key, "custom metadata test", {"customMetadata": custom}) head = await bucket.head(key) assert head is not None, "head returned None" - assert head["customMetadata"] == custom, f"custom metadata mismatch: {head['customMetadata']!r}" + assert head["customMetadata"] == custom, ( + f"custom metadata mismatch: {head['customMetadata']!r}" + ) async def test_head_object(env): @@ -152,7 +162,9 @@ async def test_list_with_prefix(env): result = await bucket.list({"prefix": "_test/prefix_a/"}) keys = [obj.key for obj in result["objects"]] assert len(keys) == 2, f"expected 2 objects, got {len(keys)}" - assert all(k.startswith("_test/prefix_a/") for k in keys), f"prefix filter failed: {keys!r}" + assert all(k.startswith("_test/prefix_a/") for k in keys), ( + f"prefix filter failed: {keys!r}" + ) async def test_list_with_limit_and_cursor(env): @@ -162,13 +174,19 @@ async def test_list_with_limit_and_cursor(env): for i in range(5): await bucket.put(f"{prefix}{i:03d}", f"val-{i}") page1 = await bucket.list({"prefix": prefix, "limit": 2}) - assert len(page1["objects"]) == 2, f"first page: expected 2, got {len(page1['objects'])}" + assert len(page1["objects"]) == 2, ( + f"first page: expected 2, got {len(page1['objects'])}" + ) assert page1["truncated"], "expected truncated=True" assert page1["cursor"] is not None, "expected cursor" page2 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) - assert len(page2["objects"]) == 2, f"second page: expected 2, got {len(page2['objects'])}" + assert len(page2["objects"]) == 2, ( + f"second page: expected 2, got {len(page2['objects'])}" + ) page3 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) - assert len(page3["objects"]) == 1, f"third page: expected 1, got {len(page3['objects'])}" + assert len(page3["objects"]) == 1, ( + f"third page: expected 1, got {len(page3['objects'])}" + ) assert not page3["truncated"], "expected truncated=False on last page" @@ -241,10 +259,14 @@ async def test_r2object_properties(env): await _cleanup_r2(bucket) key = "_test/props" content = "properties test" - obj = await bucket.put(key, content, { - "httpMetadata": {"contentType": "text/plain"}, - "customMetadata": {"foo": "bar"}, - }) + obj = await bucket.put( + key, + content, + { + "httpMetadata": {"contentType": "text/plain"}, + "customMetadata": {"foo": "bar"}, + }, + ) assert obj["key"] == key assert obj["size"] == len(content) assert isinstance(obj["version"], str) and obj["version"] @@ -261,9 +283,12 @@ async def test_multipart_upload(env): bucket = env.BUCKET await _cleanup_r2(bucket) key = "_test/multipart" - upload = await bucket.createMultipartUpload(key, { - "customMetadata": {"uploadType": "multipart_test"}, - }) + upload = await bucket.createMultipartUpload( + key, + { + "customMetadata": {"uploadType": "multipart_test"}, + }, + ) assert upload["key"] == key assert isinstance(upload["uploadId"], str) and upload["uploadId"] five_mb = 5 * 1024 * 1024 @@ -316,4 +341,4 @@ async def test_multipart_abort(env): "r2object_properties": test_r2object_properties, "multipart_upload": test_multipart_upload, "multipart_abort": test_multipart_abort, -} \ No newline at end of file +} From 852c456b3f94e71b1cc14ab35d142910bba41a5f Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 26 May 2026 19:16:25 +0900 Subject: [PATCH 03/12] chore: address comments --- packages/runtime-sdk/src/workers/_workers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index ad96de0..5b25418 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -945,11 +945,18 @@ class JsDict(dict): """ def __getattr__(self, name): + # The limitation of this approach is that if there is a key that conflicts with a built-in + # method or attribute of the dict class, it will not be accessible through attribute access. + # But that is a reasonable trade-off for the convenience of being able to access keys as + # attributes. try: return self[name] except KeyError: raise AttributeError(name) from None + def __setattr__(self, name, value): + self[name] = value + def _normalize_result(obj): """ @@ -1065,6 +1072,11 @@ def __init__(self, binding): def _convert_result(self, result): converted = python_from_rpc(result) if isinstance(converted, JsProxy): + # If the RPC result is another JsProxy, we assume that + # it is another RPC-wrapped object and wrap it as well. + # for example, d1.bind() returns the same object as a result. + # TODO: This is a bit of a hack. We should revisit when there are more + # bindings to support with different patterns. return self.__class__(converted) return converted From 270a1b64e38c71b0c5c70d3458cdad6d73e0cf4d Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 26 May 2026 19:18:09 +0900 Subject: [PATCH 04/12] chore: update rpc test --- packages/cli/tests/workerd-test/python-rpc/worker.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/workerd-test/python-rpc/worker.js b/packages/cli/tests/workerd-test/python-rpc/worker.js index b50ff10..8e0d9d8 100644 --- a/packages/cli/tests/workerd-test/python-rpc/worker.js +++ b/packages/cli/tests/workerd-test/python-rpc/worker.js @@ -34,7 +34,6 @@ export default { 1, 'test', [1, 2, 3], - new Map([['key', 42]]), 42, 1.2345, false, @@ -45,6 +44,10 @@ export default { assert.deepStrictEqual(response, val); } + // Maps are converted to Python dicts and sent back as plain objects + const mapResponse = await env.PythonRpc.identity(new Map([['key', 42]])); + assert.deepStrictEqual(mapResponse, { key: 42 }); + const null_resp = await env.PythonRpc.identity(null); assert.ok(null_resp === null || null_resp === undefined); @@ -61,4 +64,4 @@ export default { assert.deepStrictEqual(py_request.method, 'POST'); assert.equal(py_request.constructor.name, 'Request'); }, -}; +}; \ No newline at end of file From c826a7ac3e6a3d5502b3e591818d4b403100bdc6 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 26 May 2026 22:49:43 +0900 Subject: [PATCH 05/12] chore: lint --- packages/cli/tests/workerd-test/python-rpc/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/workerd-test/python-rpc/worker.js b/packages/cli/tests/workerd-test/python-rpc/worker.js index 8e0d9d8..cbbf3a6 100644 --- a/packages/cli/tests/workerd-test/python-rpc/worker.js +++ b/packages/cli/tests/workerd-test/python-rpc/worker.js @@ -64,4 +64,4 @@ export default { assert.deepStrictEqual(py_request.method, 'POST'); assert.equal(py_request.constructor.name, 'Request'); }, -}; \ No newline at end of file +}; From e202e8d702511082c059705a2bfc9f932e26348a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 29 May 2026 16:15:03 +0900 Subject: [PATCH 06/12] chore: add keyword args test cases --- .../cli/tests/bindings-test/src/d1_test.py | 20 +++++++++++++++ .../cli/tests/bindings-test/src/kv_test.py | 25 +++++++++++++++++++ .../cli/tests/bindings-test/src/r2_test.py | 24 ++++++++++++++++++ packages/cli/tests/test_bindings.py | 5 ++++ 4 files changed, 74 insertions(+) diff --git a/packages/cli/tests/bindings-test/src/d1_test.py b/packages/cli/tests/bindings-test/src/d1_test.py index 8a17a1b..4b2184e 100644 --- a/packages/cli/tests/bindings-test/src/d1_test.py +++ b/packages/cli/tests/bindings-test/src/d1_test.py @@ -419,6 +419,25 @@ async def test_invalid_sql_raises_error(env): assert raised, "expected error on invalid SQL" +async def test_raw_kwargs_column_names(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("kwargs_raw", "kv") + .run() + ) + rows = await ( + db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("kwargs_raw") + .raw(columnNames=True) + ) + assert len(rows) == 2, f"expected header + data, got {len(rows)} rows" + assert rows[0] == ["name", "value"], f"header mismatch: {rows[0]!r}" + assert rows[1] == ["kwargs_raw", "kv"], f"row mismatch: {rows[1]!r}" + + D1_TESTS = { "insert_and_select_via_run": test_insert_and_select_via_run, "all_returns_results": test_all_returns_results, @@ -437,5 +456,6 @@ async def test_invalid_sql_raises_error(env): "session_prepare_and_query": test_session_prepare_and_query, "session_bookmark": test_session_bookmark, "session_batch": test_session_batch, + "raw_kwargs_column_names": test_raw_kwargs_column_names, "invalid_sql_raises_error": test_invalid_sql_raises_error, } diff --git a/packages/cli/tests/bindings-test/src/kv_test.py b/packages/cli/tests/bindings-test/src/kv_test.py index 8b3a252..6d367ca 100644 --- a/packages/cli/tests/bindings-test/src/kv_test.py +++ b/packages/cli/tests/bindings-test/src/kv_test.py @@ -240,6 +240,29 @@ async def test_get_multiple_keys_json(env): assert result["_test:multi_json:b"]["val"] == "b" +async def test_put_kwargs_expiration_ttl(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:kwargs_ttl" + await kv.put(key, "kwargs ttl", expirationTtl=60) + result = await kv.get(key) + assert result == "kwargs ttl", f"expected 'kwargs ttl', got {result!r}" + listed = await kv.list(prefix=key) + matching = [k for k in listed["keys"] if k["name"] == key] + assert len(matching) == 1 + assert matching[0].get("expiration") is not None, "expected expiration to be set" + + +async def test_put_kwargs_metadata(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:kwargs_meta" + await kv.put(key, "kwargs meta", metadata={"source": "kwargs"}) + result = await kv.getWithMetadata(key) + assert result["value"] == "kwargs meta" + assert result["metadata"]["source"] == "kwargs" + + KV_TESTS = { "put_and_get_text": test_put_and_get_text, "get_nonexistent": test_get_nonexistent, @@ -261,4 +284,6 @@ async def test_get_multiple_keys_json(env): "get_arraybuffer": test_get_arraybuffer, "get_multiple_keys": test_get_multiple_keys, "get_multiple_keys_json": test_get_multiple_keys_json, + "put_kwargs_expiration_ttl": test_put_kwargs_expiration_ttl, + "put_kwargs_metadata": test_put_kwargs_metadata, } diff --git a/packages/cli/tests/bindings-test/src/r2_test.py b/packages/cli/tests/bindings-test/src/r2_test.py index fae90c9..3b322c7 100644 --- a/packages/cli/tests/bindings-test/src/r2_test.py +++ b/packages/cli/tests/bindings-test/src/r2_test.py @@ -320,6 +320,28 @@ async def test_multipart_abort(env): assert result is None, "object should not exist after abort" +async def test_put_kwargs_custom_metadata(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/kwargs_meta" + await bucket.put(key, "kwargs test", customMetadata={"source": "kwargs"}) + head = await bucket.head(key) + assert head is not None + assert head.customMetadata == {"source": "kwargs"} + + +async def test_list_kwargs_prefix(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + await bucket.put("_test/kw_a/1", "a1") + await bucket.put("_test/kw_a/2", "a2") + await bucket.put("_test/kw_b/1", "b1") + result = await bucket.list(prefix="_test/kw_a/") + keys = [obj.key for obj in result.objects] + assert len(keys) == 2, f"expected 2, got {len(keys)}" + assert all(k.startswith("_test/kw_a/") for k in keys) + + R2_TESTS = { "put_and_get_text": test_put_and_get_text, "put_and_get_json": test_put_and_get_json, @@ -341,4 +363,6 @@ async def test_multipart_abort(env): "r2object_properties": test_r2object_properties, "multipart_upload": test_multipart_upload, "multipart_abort": test_multipart_abort, + "put_kwargs_custom_metadata": test_put_kwargs_custom_metadata, + "list_kwargs_prefix": test_list_kwargs_prefix, } diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 1c07aab..7187e49 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -170,6 +170,8 @@ def binding_suite(suite: str, tests: list[str]) -> type: "get_arraybuffer", "get_multiple_keys", "get_multiple_keys_json", + "put_kwargs_expiration_ttl", + "put_kwargs_metadata", ], ) @@ -196,6 +198,8 @@ def binding_suite(suite: str, tests: list[str]) -> type: "r2object_properties", "multipart_upload", "multipart_abort", + "put_kwargs_custom_metadata", + "list_kwargs_prefix", ], ) @@ -219,6 +223,7 @@ def binding_suite(suite: str, tests: list[str]) -> type: "session_prepare_and_query", "session_bookmark", "session_batch", + "raw_kwargs_column_names", "invalid_sql_raises_error", ], ) From 0f586d0df4f9203de15e1b7bcb1b80481532931f Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 2 Jun 2026 15:05:22 +0900 Subject: [PATCH 07/12] chore: convert none to null by default --- .../cli/tests/bindings-test/src/d1_test.py | 67 ++++++++++++++----- .../cli/tests/bindings-test/src/kv_test.py | 18 +++++ .../cli/tests/bindings-test/src/r2_test.py | 21 ++++++ .../cli/tests/bindings-test/src/worker.py | 6 ++ packages/cli/tests/test_bindings.py | 16 +++-- .../tests/workerd-test/python-rpc/worker.js | 14 +++- .../tests/workerd-test/python-rpc/worker.py | 18 +++++ packages/runtime-sdk/src/workers/_workers.py | 29 ++++---- 8 files changed, 150 insertions(+), 39 deletions(-) diff --git a/packages/cli/tests/bindings-test/src/d1_test.py b/packages/cli/tests/bindings-test/src/d1_test.py index 4b2184e..2031419 100644 --- a/packages/cli/tests/bindings-test/src/d1_test.py +++ b/packages/cli/tests/bindings-test/src/d1_test.py @@ -171,13 +171,6 @@ async def test_bind_types(env): await _cleanup_d1(db) await _ensure_tables(db) - await ( - db.prepare( - f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" - ) - .bind(1, None, None, None) - .run() - ) await ( db.prepare( f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" @@ -196,17 +189,6 @@ async def test_bind_types(env): .run() ) - row1 = ( - await db.prepare( - f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" - ) - .bind(1) - .first() - ) - assert row1["txt"] is None, f"expected None, got {row1['txt']!r}" - assert row1["num"] is None, f"expected None, got {row1['num']!r}" - assert row1["intval"] is None, f"expected None, got {row1['intval']!r}" - row2 = ( await db.prepare( f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" @@ -438,6 +420,53 @@ async def test_raw_kwargs_column_names(env): assert rows[1] == ["kwargs_raw", "kv"], f"row mismatch: {rows[1]!r}" +async def test_bind_null(env): + import sys + + if sys.version_info < (3, 13): + from worker import SkipTest + + raise SkipTest("Pyodide 0.26 (Python 3.12) cannot represent JS null") + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await ( + db.prepare( + f"INSERT INTO {TEST_TABLE_TYPES} (id, txt, num, intval) VALUES (?, ?, ?, ?)" + ) + .bind(1, None, None, None) + .run() + ) + row = ( + await db.prepare( + f"SELECT txt, num, intval FROM {TEST_TABLE_TYPES} WHERE id = ?" + ) + .bind(1) + .first() + ) + assert row["txt"] is None, f"expected None, got {row['txt']!r}" + assert row["num"] is None, f"expected None, got {row['num']!r}" + assert row["intval"] is None, f"expected None, got {row['intval']!r}" + + +async def test_none_options_raw(env): + db = env.DB + await _cleanup_d1(db) + await _ensure_tables(db) + await ( + db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") + .bind("none_raw", "nr") + .run() + ) + rows = ( + await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("none_raw") + .raw(None) + ) + assert len(rows) == 1, f"expected 1 row, got {len(rows)}" + assert rows[0] == ["none_raw", "nr"], f"row mismatch: {rows[0]!r}" + + D1_TESTS = { "insert_and_select_via_run": test_insert_and_select_via_run, "all_returns_results": test_all_returns_results, @@ -456,6 +485,8 @@ async def test_raw_kwargs_column_names(env): "session_prepare_and_query": test_session_prepare_and_query, "session_bookmark": test_session_bookmark, "session_batch": test_session_batch, + "bind_null": test_bind_null, "raw_kwargs_column_names": test_raw_kwargs_column_names, + "none_options_raw": test_none_options_raw, "invalid_sql_raises_error": test_invalid_sql_raises_error, } diff --git a/packages/cli/tests/bindings-test/src/kv_test.py b/packages/cli/tests/bindings-test/src/kv_test.py index 6d367ca..ee9477c 100644 --- a/packages/cli/tests/bindings-test/src/kv_test.py +++ b/packages/cli/tests/bindings-test/src/kv_test.py @@ -263,6 +263,22 @@ async def test_put_kwargs_metadata(env): assert result["metadata"]["source"] == "kwargs" +async def test_none_options_put(env): + kv = env.KV + await _cleanup_kv(kv) + await kv.put("_test:none_opts", "value", None) + result = await kv.get("_test:none_opts") + assert result == "value", f"expected 'value', got {result!r}" + + +async def test_none_options_list(env): + kv = env.KV + await _cleanup_kv(kv) + await kv.put("_test:none_list", "val") + result = await kv.list(None) + assert result["list_complete"] is True + + KV_TESTS = { "put_and_get_text": test_put_and_get_text, "get_nonexistent": test_get_nonexistent, @@ -286,4 +302,6 @@ async def test_put_kwargs_metadata(env): "get_multiple_keys_json": test_get_multiple_keys_json, "put_kwargs_expiration_ttl": test_put_kwargs_expiration_ttl, "put_kwargs_metadata": test_put_kwargs_metadata, + "none_options_put": test_none_options_put, + "none_options_list": test_none_options_list, } diff --git a/packages/cli/tests/bindings-test/src/r2_test.py b/packages/cli/tests/bindings-test/src/r2_test.py index 3b322c7..3020353 100644 --- a/packages/cli/tests/bindings-test/src/r2_test.py +++ b/packages/cli/tests/bindings-test/src/r2_test.py @@ -342,6 +342,25 @@ async def test_list_kwargs_prefix(env): assert all(k.startswith("_test/kw_a/") for k in keys) +async def test_none_options_put(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + key = "_test/none_opts" + await bucket.put(key, "value", None) + body = await bucket.get(key) + assert body is not None + text = await body.text() + assert text == "value", f"expected 'value', got {text!r}" + + +async def test_none_options_list(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + await bucket.put("_test/none_list", "val") + result = await bucket.list(None) + assert len(result.objects) >= 1 + + R2_TESTS = { "put_and_get_text": test_put_and_get_text, "put_and_get_json": test_put_and_get_json, @@ -365,4 +384,6 @@ async def test_list_kwargs_prefix(env): "multipart_abort": test_multipart_abort, "put_kwargs_custom_metadata": test_put_kwargs_custom_metadata, "list_kwargs_prefix": test_list_kwargs_prefix, + "none_options_put": test_none_options_put, + "none_options_list": test_none_options_list, } diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index 3edce73..d326f21 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -40,6 +40,8 @@ async def _run_suite(self, suite_name): try: await test_fn(self.env) results[test_name] = {"status": "passed"} + except SkipTest as e: + results[test_name] = {"status": "skipped", "reason": str(e)} except AssertionError as e: results[test_name] = {"status": "failed", "error": str(e)} except Exception as e: @@ -49,3 +51,7 @@ async def _run_suite(self, suite_name): "traceback": traceback.format_exc(), } return Response.json(results) + + +class SkipTest(Exception): + pass diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 471afb9..753d979 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -72,15 +72,9 @@ def compat_date(request: pytest.FixtureRequest) -> str: @pytest.fixture(scope="module") -<<<<<<< HEAD -def dev_server(tmp_path_factory: Any) -> Generator[str]: -||||||| 806edef -def dev_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[str]: -======= def dev_server( tmp_path_factory: pytest.TempPathFactory, compat_date: str ) -> Generator[str]: ->>>>>>> origin/main """Start a pywrangler dev server on a free port and yield its base URL.""" tmp_path = tmp_path_factory.mktemp("bindings_test") target = tmp_path / "bindings-test" @@ -147,7 +141,9 @@ def test_fn(self: Any, dev_server: str) -> None: results = _get_test_results(dev_server, suite) result: BindingTestResult | None = results.get(test_name) assert result is not None, f"Test {suite}::{test_name} not found in results" - if result["status"] == "failed": + if result["status"] == "skipped": + pytest.skip(result.get("reason", "")) + elif result["status"] == "failed": pytest.fail(result["error"]) elif result["status"] == "error": pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") @@ -190,6 +186,8 @@ def binding_suite(suite: str, tests: list[str]) -> type: "get_multiple_keys_json", "put_kwargs_expiration_ttl", "put_kwargs_metadata", + "none_options_put", + "none_options_list", ], ) @@ -218,6 +216,8 @@ def binding_suite(suite: str, tests: list[str]) -> type: "multipart_abort", "put_kwargs_custom_metadata", "list_kwargs_prefix", + "none_options_put", + "none_options_list", ], ) @@ -242,6 +242,8 @@ def binding_suite(suite: str, tests: list[str]) -> type: "session_bookmark", "session_batch", "raw_kwargs_column_names", + "none_options_raw", + "bind_null", "invalid_sql_raises_error", ], ) diff --git a/packages/cli/tests/workerd-test/python-rpc/worker.js b/packages/cli/tests/workerd-test/python-rpc/worker.js index cbbf3a6..24d0b52 100644 --- a/packages/cli/tests/workerd-test/python-rpc/worker.js +++ b/packages/cli/tests/workerd-test/python-rpc/worker.js @@ -38,7 +38,6 @@ export default { 1.2345, false, true, - undefined, ]) { const response = await env.PythonRpc.identity(val); assert.deepStrictEqual(response, val); @@ -48,8 +47,19 @@ export default { const mapResponse = await env.PythonRpc.identity(new Map([['key', 42]])); assert.deepStrictEqual(mapResponse, { key: 42 }); + const hasJsnull = await env.PythonRpc.supports_jsnull(); + const expectedNullish = hasJsnull ? null : undefined; + const null_resp = await env.PythonRpc.identity(null); - assert.ok(null_resp === null || null_resp === undefined); + assert.strictEqual(null_resp, expectedNullish); + + const undef_resp = await env.PythonRpc.identity(undefined); + assert.strictEqual(undef_resp, expectedNullish); + + const nested = await env.PythonRpc.identity({a: 1, b: null, c: {d: null}}); + assert.strictEqual(nested.a, 1); + assert.strictEqual(nested.b, expectedNullish); + assert.strictEqual(nested.c.d, expectedNullish); // Web/API Types const py_response = await env.PythonRpc.handle_response( diff --git a/packages/cli/tests/workerd-test/python-rpc/worker.py b/packages/cli/tests/workerd-test/python-rpc/worker.py index f506ef6..1ad067d 100644 --- a/packages/cli/tests/workerd-test/python-rpc/worker.py +++ b/packages/cli/tests/workerd-test/python-rpc/worker.py @@ -41,6 +41,14 @@ async def identity(self, x): assert not isinstance(x, JsProxy) return x + async def supports_jsnull(self): + try: + from pyodide.ffi import jsnull # noqa: F401 + + return True + except ImportError: + return False + async def handle_response(self, response): # Verify that we receive a Python object here... assert isinstance(response, Response) @@ -197,6 +205,16 @@ async def test(ctrl, env, ctx): # noqa: PLR0915 js_undefined = await env.PythonRpc.identity(js.undefined) assert js_undefined is None + nested_none = await env.PythonRpc.identity({"a": 1, "b": None, "c": {"d": None}}) + assert nested_none["a"] == 1 + assert nested_none["b"] is None + assert nested_none["c"]["d"] is None + + nested_none_js = await env.JsRpc.identity({"a": 1, "b": None, "c": {"d": None}}) + assert nested_none_js["a"] == 1 + assert nested_none_js["b"] is None + assert nested_none_js["c"]["d"] is None + js_date = await env.PythonRpc.identity(js.Date.new()) assert isinstance(js_date, datetime) diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 5b25418..577e2b6 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1021,7 +1021,7 @@ def _raise_on_disabled_type(value): def _python_to_rpc_default_converter(obj, convert, cache): if obj is None: - return obj + return jsnull if isinstance(obj, _RPCWrapper): return obj._binding @@ -1041,6 +1041,16 @@ def _python_to_rpc_default_converter(obj, convert, cache): return obj +def _replace_none_with_jsnull(value): + if value is None: + return jsnull + if isinstance(value, dict): + return {k: _replace_none_with_jsnull(v) for k, v in value.items()} + if isinstance(value, list): + return [_replace_none_with_jsnull(v) for v in value] + return value + + def python_to_rpc(value) -> JsProxy: """ Converts Python objects defined in this module (Response, Request, etc) and native Python types @@ -1050,9 +1060,14 @@ def python_to_rpc(value) -> JsProxy: it does not support serializing all Python object types. """ + if value is None: + return jsnull + if isinstance(value, _RPCWrapper): return value._binding + value = _replace_none_with_jsnull(value) + # `to_js` won't always call the default_converter, for example when a list of tuples is passed _raise_on_disabled_type(value) @@ -1114,16 +1129,6 @@ def fetch(self, *args, **kwargs): return fetch(*args, fetcher=self._binding.fetch, **kwargs) -class _D1DatabaseWrapper(_RPCWrapper): - def bind(self, *args): - """ - D1.bind() requires extra conversion (None => jsnull). - """ - js_args = [jsnull if a is None else python_to_rpc(a) for a in args] - result = self._binding.bind(*js_args) - return self._convert_result(result) - - class _DurableObjectNamespaceWrapper: def __init__(self, binding): self._binding = binding @@ -1244,7 +1249,7 @@ def _getattr_helper(self, name): return _RPCWrapper(binding) if _is_js_instance(binding, "D1Database"): - return _D1DatabaseWrapper(binding) + return _RPCWrapper(binding) return binding From 8ddb39a64a920e0840951e9740c0389f4725b715 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 13:48:05 +0900 Subject: [PATCH 08/12] chore: apply #119 --- .../cli/tests/bindings-test/pyproject.toml | 2 +- .../cli/tests/bindings-test/src/conftest.py | 9 ++ .../src/{d1_test.py => test_d1.py} | 52 +++--- .../src/{kv_test.py => test_kv.py} | 54 +++---- .../src/{r2_test.py => test_r2.py} | 54 +++---- .../cli/tests/bindings-test/src/worker.py | 137 +++++++++++----- packages/cli/tests/test_bindings.py | 151 ++++++------------ 7 files changed, 236 insertions(+), 223 deletions(-) create mode 100644 packages/cli/tests/bindings-test/src/conftest.py rename packages/cli/tests/bindings-test/src/{d1_test.py => test_d1.py} (92%) rename packages/cli/tests/bindings-test/src/{kv_test.py => test_kv.py} (88%) rename packages/cli/tests/bindings-test/src/{r2_test.py => test_r2.py} (90%) diff --git a/packages/cli/tests/bindings-test/pyproject.toml b/packages/cli/tests/bindings-test/pyproject.toml index 658789f..031bfa1 100644 --- a/packages/cli/tests/bindings-test/pyproject.toml +++ b/packages/cli/tests/bindings-test/pyproject.toml @@ -2,4 +2,4 @@ name = "bindings-test" version = "0.1.0" requires-python = ">=3.12" -dependencies = [] +dependencies = ["pytest", "pytest-asyncio<1.2.0"] diff --git a/packages/cli/tests/bindings-test/src/conftest.py b/packages/cli/tests/bindings-test/src/conftest.py new file mode 100644 index 0000000..f18c4a6 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/conftest.py @@ -0,0 +1,9 @@ +# pyright: reportMissingImports=false + +import pytest +from workers import env as _env + + +@pytest.fixture +def env(): + return _env diff --git a/packages/cli/tests/bindings-test/src/d1_test.py b/packages/cli/tests/bindings-test/src/test_d1.py similarity index 92% rename from packages/cli/tests/bindings-test/src/d1_test.py rename to packages/cli/tests/bindings-test/src/test_d1.py index 2031419..34b2a87 100644 --- a/packages/cli/tests/bindings-test/src/d1_test.py +++ b/packages/cli/tests/bindings-test/src/test_d1.py @@ -1,3 +1,5 @@ +import pytest + TEST_TABLE = "_test_d1" TEST_TABLE_TYPES = "_test_d1_types" TEST_TABLE_BATCH = "_test_d1_batch" @@ -34,6 +36,7 @@ async def _ensure_tables(db): ) +@pytest.mark.asyncio async def test_insert_and_select_via_run(env): db = env.DB await _cleanup_d1(db) @@ -59,6 +62,7 @@ async def test_insert_and_select_via_run(env): assert rows[0]["value"] == "hello" +@pytest.mark.asyncio async def test_all_returns_results(env): db = env.DB await _cleanup_d1(db) @@ -79,6 +83,7 @@ async def test_all_returns_results(env): assert rows[0]["name"] == "all_test" +@pytest.mark.asyncio async def test_first_returns_single_row(env): db = env.DB await _cleanup_d1(db) @@ -99,6 +104,7 @@ async def test_first_returns_single_row(env): assert row["value"] == "fv" +@pytest.mark.asyncio async def test_first_with_column_name(env): db = env.DB await _cleanup_d1(db) @@ -116,6 +122,7 @@ async def test_first_with_column_name(env): assert value == "col_val", f"expected 'col_val', got {value!r}" +@pytest.mark.asyncio async def test_first_on_empty_result(env): db = env.DB await _cleanup_d1(db) @@ -128,6 +135,7 @@ async def test_first_on_empty_result(env): assert row is None, f"expected None, got {row!r}" +@pytest.mark.asyncio async def test_raw_returns_arrays(env): db = env.DB await _cleanup_d1(db) @@ -147,6 +155,7 @@ async def test_raw_returns_arrays(env): assert rows[0] == ["raw_test", "rv"], f"row mismatch: {rows!r}" +@pytest.mark.asyncio async def test_raw_with_column_names(env): db = env.DB await _cleanup_d1(db) @@ -166,6 +175,7 @@ async def test_raw_with_column_names(env): assert rows[1] == ["raw_cols", "rc"], f"row mismatch: {rows[1]!r}" +@pytest.mark.asyncio async def test_bind_types(env): db = env.DB await _cleanup_d1(db) @@ -214,6 +224,7 @@ async def test_bind_types(env): assert row4["intval"] == 0, f"False should be 0, got {row4['intval']}" +@pytest.mark.asyncio async def test_exec_create_and_query(env): db = env.DB await _cleanup_d1(db) @@ -224,6 +235,7 @@ async def test_exec_create_and_query(env): assert result["duration"] >= 0, f"expected duration >= 0, got {result['duration']}" +@pytest.mark.asyncio async def test_exec_multiple_statements(env): db = env.DB await _cleanup_d1(db) @@ -237,6 +249,7 @@ async def test_exec_multiple_statements(env): assert rows == [["a"], ["b"]], f"row mismatch: {rows!r}" +@pytest.mark.asyncio async def test_batch_multiple_inserts(env): db = env.DB await _cleanup_d1(db) @@ -262,6 +275,7 @@ async def test_batch_multiple_inserts(env): assert [row["val"] for row in rows] == ["batch_a", "batch_b", "batch_c"] +@pytest.mark.asyncio async def test_run_metadata_fields(env): db = env.DB await _cleanup_d1(db) @@ -287,6 +301,7 @@ async def test_run_metadata_fields(env): assert meta["changed_db"] is True +@pytest.mark.asyncio async def test_update_row(env): db = env.DB await _cleanup_d1(db) @@ -311,6 +326,7 @@ async def test_update_row(env): assert row["value"] == "new_value", f"expected 'new_value', got {row['value']!r}" +@pytest.mark.asyncio async def test_delete_row(env): db = env.DB await _cleanup_d1(db) @@ -333,6 +349,7 @@ async def test_delete_row(env): assert row is None, f"row should be deleted, got {row!r}" +@pytest.mark.asyncio async def test_session_prepare_and_query(env): db = env.DB await _cleanup_d1(db) @@ -349,6 +366,7 @@ async def test_session_prepare_and_query(env): assert result["results"][0]["cnt"] >= 1 +@pytest.mark.asyncio async def test_session_bookmark(env): db = env.DB await _cleanup_d1(db) @@ -367,6 +385,7 @@ async def test_session_bookmark(env): assert len(bookmark_after) > 0, "bookmark should be non-empty" +@pytest.mark.asyncio async def test_session_batch(env): db = env.DB await _cleanup_d1(db) @@ -390,6 +409,7 @@ async def test_session_batch(env): assert rows["results"][1]["val"] == "sb" +@pytest.mark.asyncio async def test_invalid_sql_raises_error(env): db = env.DB await _cleanup_d1(db) @@ -401,6 +421,7 @@ async def test_invalid_sql_raises_error(env): assert raised, "expected error on invalid SQL" +@pytest.mark.asyncio async def test_raw_kwargs_column_names(env): db = env.DB await _cleanup_d1(db) @@ -420,13 +441,12 @@ async def test_raw_kwargs_column_names(env): assert rows[1] == ["kwargs_raw", "kv"], f"row mismatch: {rows[1]!r}" +@pytest.mark.asyncio async def test_bind_null(env): import sys if sys.version_info < (3, 13): - from worker import SkipTest - - raise SkipTest("Pyodide 0.26 (Python 3.12) cannot represent JS null") + pytest.skip("Pyodide 0.26 (Python 3.12) cannot represent JS null") db = env.DB await _cleanup_d1(db) await _ensure_tables(db) @@ -449,6 +469,7 @@ async def test_bind_null(env): assert row["intval"] is None, f"expected None, got {row['intval']!r}" +@pytest.mark.asyncio async def test_none_options_raw(env): db = env.DB await _cleanup_d1(db) @@ -465,28 +486,3 @@ async def test_none_options_raw(env): ) assert len(rows) == 1, f"expected 1 row, got {len(rows)}" assert rows[0] == ["none_raw", "nr"], f"row mismatch: {rows[0]!r}" - - -D1_TESTS = { - "insert_and_select_via_run": test_insert_and_select_via_run, - "all_returns_results": test_all_returns_results, - "first_returns_single_row": test_first_returns_single_row, - "first_with_column_name": test_first_with_column_name, - "first_on_empty_result": test_first_on_empty_result, - "raw_returns_arrays": test_raw_returns_arrays, - "raw_with_column_names": test_raw_with_column_names, - "bind_types": test_bind_types, - "exec_create_and_query": test_exec_create_and_query, - "exec_multiple_statements": test_exec_multiple_statements, - "batch_multiple_inserts": test_batch_multiple_inserts, - "run_metadata_fields": test_run_metadata_fields, - "update_row": test_update_row, - "delete_row": test_delete_row, - "session_prepare_and_query": test_session_prepare_and_query, - "session_bookmark": test_session_bookmark, - "session_batch": test_session_batch, - "bind_null": test_bind_null, - "raw_kwargs_column_names": test_raw_kwargs_column_names, - "none_options_raw": test_none_options_raw, - "invalid_sql_raises_error": test_invalid_sql_raises_error, -} diff --git a/packages/cli/tests/bindings-test/src/kv_test.py b/packages/cli/tests/bindings-test/src/test_kv.py similarity index 88% rename from packages/cli/tests/bindings-test/src/kv_test.py rename to packages/cli/tests/bindings-test/src/test_kv.py index ee9477c..27a30ae 100644 --- a/packages/cli/tests/bindings-test/src/kv_test.py +++ b/packages/cli/tests/bindings-test/src/test_kv.py @@ -1,5 +1,7 @@ import json +import pytest + async def _cleanup_kv(kv): cursor = None @@ -15,6 +17,7 @@ async def _cleanup_kv(kv): cursor = result.get("cursor") +@pytest.mark.asyncio async def test_put_and_get_text(env): kv = env.KV await _cleanup_kv(kv) @@ -25,6 +28,7 @@ async def test_put_and_get_text(env): assert result == value, f"expected {value!r}, got {result!r}" +@pytest.mark.asyncio async def test_get_nonexistent(env): kv = env.KV await _cleanup_kv(kv) @@ -32,6 +36,7 @@ async def test_get_nonexistent(env): assert result is None, f"expected None, got {result!r}" +@pytest.mark.asyncio async def test_put_and_get_json(env): kv = env.KV await _cleanup_kv(kv) @@ -43,6 +48,7 @@ async def test_put_and_get_json(env): assert result == payload, f"json mismatch: {result!r}" +@pytest.mark.asyncio async def test_put_overwrite(env): kv = env.KV await _cleanup_kv(kv) @@ -55,6 +61,7 @@ async def test_put_overwrite(env): assert second == "version2" +@pytest.mark.asyncio async def test_put_empty_value(env): kv = env.KV await _cleanup_kv(kv) @@ -64,6 +71,7 @@ async def test_put_empty_value(env): assert result == "", f"expected empty string, got {result!r}" +@pytest.mark.asyncio async def test_delete(env): kv = env.KV await _cleanup_kv(kv) @@ -75,12 +83,14 @@ async def test_delete(env): assert result is None, f"expected None after delete, got {result!r}" +@pytest.mark.asyncio async def test_delete_nonexistent(env): kv = env.KV await _cleanup_kv(kv) await kv.delete("_test:does_not_exist_67890") +@pytest.mark.asyncio async def test_put_with_metadata(env): kv = env.KV await _cleanup_kv(kv) @@ -93,6 +103,7 @@ async def test_put_with_metadata(env): assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" +@pytest.mark.asyncio async def test_get_with_metadata_nonexistent(env): kv = env.KV await _cleanup_kv(kv) @@ -103,6 +114,7 @@ async def test_get_with_metadata_nonexistent(env): ) +@pytest.mark.asyncio async def test_put_with_expiration_ttl(env): kv = env.KV await _cleanup_kv(kv) @@ -116,6 +128,7 @@ async def test_put_with_expiration_ttl(env): assert matching[0].get("expiration") is not None, "expected expiration to be set" +@pytest.mark.asyncio async def test_list_basic(env): kv = env.KV await _cleanup_kv(kv) @@ -130,6 +143,7 @@ async def test_list_basic(env): assert f"_test:list_basic:{i}" in names, f"missing key {i}" +@pytest.mark.asyncio async def test_list_with_prefix(env): kv = env.KV await _cleanup_kv(kv) @@ -144,6 +158,7 @@ async def test_list_with_prefix(env): ) +@pytest.mark.asyncio async def test_list_with_limit_and_cursor(env): kv = env.KV await _cleanup_kv(kv) @@ -161,6 +176,7 @@ async def test_list_with_limit_and_cursor(env): assert page3["list_complete"], "expected list_complete=True on last page" +@pytest.mark.asyncio async def test_list_empty_prefix(env): kv = env.KV await _cleanup_kv(kv) @@ -169,6 +185,7 @@ async def test_list_empty_prefix(env): assert result["list_complete"] +@pytest.mark.asyncio async def test_list_with_metadata(env): kv = env.KV await _cleanup_kv(kv) @@ -184,6 +201,7 @@ async def test_list_with_metadata(env): ) +@pytest.mark.asyncio async def test_get_with_metadata_has_metadata(env): kv = env.KV await _cleanup_kv(kv) @@ -195,6 +213,7 @@ async def test_get_with_metadata_has_metadata(env): assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" +@pytest.mark.asyncio async def test_get_type_as_options_dict(env): kv = env.KV await _cleanup_kv(kv) @@ -206,6 +225,7 @@ async def test_get_type_as_options_dict(env): assert result["x"] == "hello" +@pytest.mark.asyncio async def test_get_arraybuffer(env): kv = env.KV await _cleanup_kv(kv) @@ -217,6 +237,7 @@ async def test_get_arraybuffer(env): assert len(result) > 0 +@pytest.mark.asyncio async def test_get_multiple_keys(env): kv = env.KV await _cleanup_kv(kv) @@ -229,6 +250,7 @@ async def test_get_multiple_keys(env): assert result.get("_test:multi:nonexistent") is None +@pytest.mark.asyncio async def test_get_multiple_keys_json(env): kv = env.KV await _cleanup_kv(kv) @@ -240,6 +262,7 @@ async def test_get_multiple_keys_json(env): assert result["_test:multi_json:b"]["val"] == "b" +@pytest.mark.asyncio async def test_put_kwargs_expiration_ttl(env): kv = env.KV await _cleanup_kv(kv) @@ -253,6 +276,7 @@ async def test_put_kwargs_expiration_ttl(env): assert matching[0].get("expiration") is not None, "expected expiration to be set" +@pytest.mark.asyncio async def test_put_kwargs_metadata(env): kv = env.KV await _cleanup_kv(kv) @@ -263,6 +287,7 @@ async def test_put_kwargs_metadata(env): assert result["metadata"]["source"] == "kwargs" +@pytest.mark.asyncio async def test_none_options_put(env): kv = env.KV await _cleanup_kv(kv) @@ -271,37 +296,10 @@ async def test_none_options_put(env): assert result == "value", f"expected 'value', got {result!r}" +@pytest.mark.asyncio async def test_none_options_list(env): kv = env.KV await _cleanup_kv(kv) await kv.put("_test:none_list", "val") result = await kv.list(None) assert result["list_complete"] is True - - -KV_TESTS = { - "put_and_get_text": test_put_and_get_text, - "get_nonexistent": test_get_nonexistent, - "put_and_get_json": test_put_and_get_json, - "put_overwrite": test_put_overwrite, - "put_empty_value": test_put_empty_value, - "delete": test_delete, - "delete_nonexistent": test_delete_nonexistent, - "put_with_metadata": test_put_with_metadata, - "get_with_metadata_nonexistent": test_get_with_metadata_nonexistent, - "put_with_expiration_ttl": test_put_with_expiration_ttl, - "list_basic": test_list_basic, - "list_with_prefix": test_list_with_prefix, - "list_with_limit_and_cursor": test_list_with_limit_and_cursor, - "list_empty_prefix": test_list_empty_prefix, - "list_with_metadata": test_list_with_metadata, - "get_with_metadata_has_metadata": test_get_with_metadata_has_metadata, - "get_type_as_options_dict": test_get_type_as_options_dict, - "get_arraybuffer": test_get_arraybuffer, - "get_multiple_keys": test_get_multiple_keys, - "get_multiple_keys_json": test_get_multiple_keys_json, - "put_kwargs_expiration_ttl": test_put_kwargs_expiration_ttl, - "put_kwargs_metadata": test_put_kwargs_metadata, - "none_options_put": test_none_options_put, - "none_options_list": test_none_options_list, -} diff --git a/packages/cli/tests/bindings-test/src/r2_test.py b/packages/cli/tests/bindings-test/src/test_r2.py similarity index 90% rename from packages/cli/tests/bindings-test/src/r2_test.py rename to packages/cli/tests/bindings-test/src/test_r2.py index 3020353..b2209c2 100644 --- a/packages/cli/tests/bindings-test/src/r2_test.py +++ b/packages/cli/tests/bindings-test/src/test_r2.py @@ -1,5 +1,7 @@ import json +import pytest + async def _cleanup_r2(bucket): cursor = None @@ -16,6 +18,7 @@ async def _cleanup_r2(bucket): cursor = result.cursor +@pytest.mark.asyncio async def test_put_and_get_text(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -32,6 +35,7 @@ async def test_put_and_get_text(env): assert body["bodyUsed"] is True +@pytest.mark.asyncio async def test_put_and_get_json(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -50,6 +54,7 @@ async def test_put_and_get_json(env): assert parsed == payload, f"json mismatch: {parsed!r}" +@pytest.mark.asyncio async def test_put_with_http_metadata(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -75,6 +80,7 @@ async def test_put_with_http_metadata(env): assert meta["cacheControl"] == "max-age=3600" +@pytest.mark.asyncio async def test_put_with_custom_metadata(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -88,6 +94,7 @@ async def test_put_with_custom_metadata(env): ) +@pytest.mark.asyncio async def test_head_object(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -103,6 +110,7 @@ async def test_head_object(env): assert head["version"] is not None +@pytest.mark.asyncio async def test_get_nonexistent(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -110,6 +118,7 @@ async def test_get_nonexistent(env): assert result is None, f"expected None, got {result!r}" +@pytest.mark.asyncio async def test_head_nonexistent(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -117,6 +126,7 @@ async def test_head_nonexistent(env): assert result is None, f"expected None, got {result!r}" +@pytest.mark.asyncio async def test_delete_single(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -128,6 +138,7 @@ async def test_delete_single(env): assert result is None, "object still exists after delete" +@pytest.mark.asyncio async def test_delete_multiple(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -140,6 +151,7 @@ async def test_delete_multiple(env): assert result is None, f"{key} still exists after batch delete" +@pytest.mark.asyncio async def test_list_basic(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -153,6 +165,7 @@ async def test_list_basic(env): assert f"_test/list_basic/{i}" in keys, f"missing key {i}" +@pytest.mark.asyncio async def test_list_with_prefix(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -167,6 +180,7 @@ async def test_list_with_prefix(env): ) +@pytest.mark.asyncio async def test_list_with_limit_and_cursor(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -190,6 +204,7 @@ async def test_list_with_limit_and_cursor(env): assert not page3["truncated"], "expected truncated=False on last page" +@pytest.mark.asyncio async def test_list_with_delimiter(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -205,6 +220,7 @@ async def test_list_with_delimiter(env): assert "_test/delim/dir2/" in prefixes, f"missing dir2 prefix: {prefixes!r}" +@pytest.mark.asyncio async def test_overwrite_object(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -217,6 +233,7 @@ async def test_overwrite_object(env): assert second == "version2" +@pytest.mark.asyncio async def test_put_empty_body(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -230,6 +247,7 @@ async def test_put_empty_body(env): assert text == "", f"expected empty string, got {text!r}" +@pytest.mark.asyncio async def test_get_range_offset_length(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -242,6 +260,7 @@ async def test_get_range_offset_length(env): assert text == "456789", f"range mismatch: {text!r}" +@pytest.mark.asyncio async def test_get_range_suffix(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -254,6 +273,7 @@ async def test_get_range_suffix(env): assert text == "CDEF", f"suffix mismatch: {text!r}" +@pytest.mark.asyncio async def test_r2object_properties(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -279,6 +299,7 @@ async def test_r2object_properties(env): assert head["customMetadata"] == {"foo": "bar"} +@pytest.mark.asyncio async def test_multipart_upload(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -308,6 +329,7 @@ async def test_multipart_upload(env): assert text == part1_data + part2_data, "multipart content mismatch" +@pytest.mark.asyncio async def test_multipart_abort(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -320,6 +342,7 @@ async def test_multipart_abort(env): assert result is None, "object should not exist after abort" +@pytest.mark.asyncio async def test_put_kwargs_custom_metadata(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -330,6 +353,7 @@ async def test_put_kwargs_custom_metadata(env): assert head.customMetadata == {"source": "kwargs"} +@pytest.mark.asyncio async def test_list_kwargs_prefix(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -342,6 +366,7 @@ async def test_list_kwargs_prefix(env): assert all(k.startswith("_test/kw_a/") for k in keys) +@pytest.mark.asyncio async def test_none_options_put(env): bucket = env.BUCKET await _cleanup_r2(bucket) @@ -353,37 +378,10 @@ async def test_none_options_put(env): assert text == "value", f"expected 'value', got {text!r}" +@pytest.mark.asyncio async def test_none_options_list(env): bucket = env.BUCKET await _cleanup_r2(bucket) await bucket.put("_test/none_list", "val") result = await bucket.list(None) assert len(result.objects) >= 1 - - -R2_TESTS = { - "put_and_get_text": test_put_and_get_text, - "put_and_get_json": test_put_and_get_json, - "put_with_http_metadata": test_put_with_http_metadata, - "put_with_custom_metadata": test_put_with_custom_metadata, - "head_object": test_head_object, - "get_nonexistent": test_get_nonexistent, - "head_nonexistent": test_head_nonexistent, - "delete_single": test_delete_single, - "delete_multiple": test_delete_multiple, - "list_basic": test_list_basic, - "list_with_prefix": test_list_with_prefix, - "list_with_limit_and_cursor": test_list_with_limit_and_cursor, - "list_with_delimiter": test_list_with_delimiter, - "overwrite_object": test_overwrite_object, - "put_empty_body": test_put_empty_body, - "get_range_offset_length": test_get_range_offset_length, - "get_range_suffix": test_get_range_suffix, - "r2object_properties": test_r2object_properties, - "multipart_upload": test_multipart_upload, - "multipart_abort": test_multipart_abort, - "put_kwargs_custom_metadata": test_put_kwargs_custom_metadata, - "list_kwargs_prefix": test_list_kwargs_prefix, - "none_options_put": test_none_options_put, - "none_options_list": test_none_options_list, -} diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index d326f21..96a2c21 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -1,15 +1,96 @@ -import traceback +"""Bindings test worker. -from d1_test import D1_TESTS -from kv_test import KV_TESTS -from r2_test import R2_TESTS +Each binding suite lives in a `test_.py` module written as ordinary pytest +tests (see test_kv.py). The `/run-tests/` endpoint runs pytest against that +module inside workerd and returns per-test results as JSON, which the host-side +test_bindings.py maps onto individual pytest cases. + +To add a new binding: create `src/test_.py` with pytest tests. +""" + +import asyncio +import importlib.util +import sys + +import pytest +from pyodide.webloop import WebLoop from workers import Response, WorkerEntrypoint -ALL_TESTS = { - "kv": KV_TESTS, - "r2": R2_TESTS, - "d1": D1_TESTS, -} + +async def _noop(*args): + pass + + +# pytest-asyncio relies on these but in Pyodide < 0.29 WebLoop does not implement them. +WebLoop.shutdown_asyncgens = _noop +WebLoop.shutdown_default_executor = _noop + +# Pyodide 0.26.0a2's _cancel_all_tasks calls task.exception() on pending tasks, +# which raises InvalidStateError under Pyodide's WebLoop. +# Ignore this error to prevent pytest-asyncio from crashing. +if sys.version_info < (3, 13): + asyncio.runners._cancel_all_tasks = lambda loop: None # type: ignore[attr-defined] + + +class ResultCollector: + """pytest plugin that records each test's outcome keyed by its short name. + + The "test_" prefix is stripped so keys match the names registered in + tests/test_bindings.py (e.g. test_put_and_get -> "put_and_get"). + """ + + def __init__(self): + self.results = {} + + @staticmethod + def _key(item): + name = item.name + return name[len("test_") :] if name.startswith("test_") else name + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_makereport(self, item, call): + outcome = yield + report = outcome.get_result() + key = self._key(item) + + if report.when == "call": + if report.passed: + self.results[key] = {"status": "passed"} + elif report.skipped: + self.results[key] = { + "status": "skipped", + "reason": str(report.longrepr), + } + elif report.failed: + excinfo = call.excinfo + if excinfo is not None and excinfo.errisinstance(AssertionError): + self.results[key] = { + "status": "failed", + "error": str(excinfo.value), + } + else: + self.results[key] = { + "status": "error", + "error": f"{excinfo.typename}: {excinfo.value}" + if excinfo is not None + else "unknown error", + "traceback": report.longreprtext, + } + elif report.when in ("setup", "teardown") and report.failed: + self.results[key] = { + "status": "error", + "error": report.longreprtext, + "traceback": report.longreprtext, + } + + +class EnvPlugin: + def __init__(self, env): + self._env = env + + @pytest.fixture + def env(self): + return self._env class Default(WorkerEntrypoint): @@ -20,38 +101,22 @@ async def fetch(self, request): if path.startswith("/run-tests/"): suite_name = path[len("/run-tests/") :] - return await self._run_suite(suite_name) + return self._run_suite(suite_name) if path == "/health": - # health check used in test to make sure the worker is up and running return Response.json({"ok": True}) return Response.json({"error": "not found"}, status=404) - async def _run_suite(self, suite_name): - tests = ALL_TESTS.get(suite_name) - if tests is None: - available = list(ALL_TESTS.keys()) + def _run_suite(self, suite_name): + module = f"test_{suite_name}" + if importlib.util.find_spec(module) is None: return Response.json( - {"error": f"Unknown suite '{suite_name}'", "available": available}, + {"error": f"Unknown suite '{suite_name}' (no module '{module}')"}, status=404, ) - results = {} - for test_name, test_fn in tests.items(): - try: - await test_fn(self.env) - results[test_name] = {"status": "passed"} - except SkipTest as e: - results[test_name] = {"status": "skipped", "reason": str(e)} - except AssertionError as e: - results[test_name] = {"status": "failed", "error": str(e)} - except Exception as e: - results[test_name] = { - "status": "error", - "error": f"{type(e).__name__}: {e}", - "traceback": traceback.format_exc(), - } - return Response.json(results) - - -class SkipTest(Exception): - pass + collector = ResultCollector() + pytest.main( + ["--pyargs", module, "-p", "no:cacheprovider"], + plugins=[collector, EnvPlugin(self.env)], + ) + return Response.json(collector.results) diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 753d979..9b163d2 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -4,17 +4,23 @@ binding tests inside workerd and return JSON results. This file starts the dev server, calls those endpoints, and maps each in-worker test to a pytest test case. -To add a new binding: create src/_test.py in bindings-test/, register it in -worker.py's ALL_TESTS, then add a TestXxx class below. +The in-worker tests are ordinary pytest modules (src/test_.py); worker.py runs +pytest against them and returns per-test results. + +To add a new binding: create src/test_.py in bindings-test/ with pytest tests +and add any required binding to wrangler.jsonc. """ +import ast +import functools +import os import shutil import socket import subprocess import time from collections.abc import Callable, Generator from pathlib import Path -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, TypedDict import pytest import requests @@ -22,6 +28,7 @@ TEST_DIR: Path = Path(__file__).parent BINDINGS_TEST_DIR: Path = TEST_DIR / "bindings-test" +BINDINGS_SRC_DIR: Path = BINDINGS_TEST_DIR / "src" WORKERS_PY: Path = TEST_DIR.parent WORKERS_RUNTIME_SDK: Path = WORKERS_PY.parent / "runtime-sdk" / "src" @@ -30,9 +37,10 @@ class BindingTestResult(TypedDict): - status: Literal["passed", "failed", "error"] - error: NotRequired[str] - traceback: NotRequired[str] + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str SuiteResults = dict[str, BindingTestResult] @@ -79,6 +87,7 @@ def dev_server( tmp_path = tmp_path_factory.mktemp("bindings_test") target = tmp_path / "bindings-test" shutil.copytree(BINDINGS_TEST_DIR, target) + env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} replace_compat_date(target / "wrangler.jsonc", compat_date) @@ -86,6 +95,7 @@ def dev_server( ["uv", "run", "--with", WORKERS_PY, "pywrangler", "sync"], cwd=target, check=True, + env=env, ) shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) @@ -110,6 +120,7 @@ def dev_server( stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env=env, ) _wait_for_ready(process, base_url) @@ -123,17 +134,11 @@ def dev_server( process.wait() -# key: (dev server url, test suite) -_test_suite_cache: dict[tuple[str, str], SuiteResults] = {} - - +@functools.cache def _get_test_results(dev_server: str, suite: str) -> SuiteResults: - key = (dev_server, suite) - if key not in _test_suite_cache: - resp = requests.get(f"{dev_server}/run-tests/{suite}", timeout=60) - assert resp.ok, f"Suite '{suite}' returned {resp.status_code}: {resp.text}" - _test_suite_cache[key] = resp.json() - return _test_suite_cache[key] + resp = requests.get(f"{dev_server}/run-tests/{suite}", timeout=60) + assert resp.ok, f"Suite '{suite}' returned {resp.status_code}: {resp.text}" + return resp.json() def _make_test(suite: str, test_name: str) -> Callable: @@ -161,89 +166,31 @@ def binding_suite(suite: str, tests: list[str]) -> type: ) -TestKV = binding_suite( - "kv", - [ - "put_and_get_text", - "get_nonexistent", - "put_and_get_json", - "put_overwrite", - "put_empty_value", - "delete", - "delete_nonexistent", - "put_with_metadata", - "get_with_metadata_nonexistent", - "put_with_expiration_ttl", - "list_basic", - "list_with_prefix", - "list_with_limit_and_cursor", - "list_empty_prefix", - "list_with_metadata", - "get_with_metadata_has_metadata", - "get_type_as_options_dict", - "get_arraybuffer", - "get_multiple_keys", - "get_multiple_keys_json", - "put_kwargs_expiration_ttl", - "put_kwargs_metadata", - "none_options_put", - "none_options_list", - ], -) - -TestR2 = binding_suite( - "r2", - [ - "put_and_get_text", - "put_and_get_json", - "put_with_http_metadata", - "put_with_custom_metadata", - "head_object", - "get_nonexistent", - "head_nonexistent", - "delete_single", - "delete_multiple", - "list_basic", - "list_with_prefix", - "list_with_limit_and_cursor", - "list_with_delimiter", - "overwrite_object", - "put_empty_body", - "get_range_offset_length", - "get_range_suffix", - "r2object_properties", - "multipart_upload", - "multipart_abort", - "put_kwargs_custom_metadata", - "list_kwargs_prefix", - "none_options_put", - "none_options_list", - ], -) - -TestD1 = binding_suite( - "d1", - [ - "insert_and_select_via_run", - "all_returns_results", - "first_returns_single_row", - "first_with_column_name", - "first_on_empty_result", - "raw_returns_arrays", - "raw_with_column_names", - "bind_types", - "exec_create_and_query", - "exec_multiple_statements", - "batch_multiple_inserts", - "run_metadata_fields", - "update_row", - "delete_row", - "session_prepare_and_query", - "session_bookmark", - "session_batch", - "raw_kwargs_column_names", - "none_options_raw", - "bind_null", - "invalid_sql_raises_error", - ], -) +def _discover_test_names(module_path: Path) -> list[str]: + """Return the suite-relative names of test functions defined in a module. + + Parses the source statically (no import) and strips the ``test_`` prefix so + the names match the keys returned by the in-worker ResultCollector. + """ + tree = ast.parse(module_path.read_text()) + return [ + node.name[len("test_") :] + for node in tree.body + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + and node.name.startswith("test_") + ] + + +def _discover_suites() -> dict[str, list[str]]: + """Map each ``test_.py`` module to its discovered test names.""" + return { + module_path.stem[len("test_") :]: _discover_test_names(module_path) + for module_path in sorted(BINDINGS_SRC_DIR.glob("test_*.py")) + } + + +# Generate a TestXxx class per discovered suite so each in-worker test surfaces +# as its own pytest case without manual registration. +for _suite, _test_names in _discover_suites().items(): + _suite_cls = binding_suite(_suite, _test_names) + globals()[_suite_cls.__name__] = _suite_cls From a058809eba8daa9bd86fadaae30e326b9a3723e3 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 5 Jun 2026 14:02:27 +0900 Subject: [PATCH 09/12] chore: remove dedundant assert messages --- .../cli/tests/bindings-test/src/test_d1.py | 79 +++++++++---------- .../cli/tests/bindings-test/src/test_kv.py | 57 ++++++------- .../cli/tests/bindings-test/src/test_r2.py | 32 ++++---- 3 files changed, 78 insertions(+), 90 deletions(-) diff --git a/packages/cli/tests/bindings-test/src/test_d1.py b/packages/cli/tests/bindings-test/src/test_d1.py index 34b2a87..a657fb8 100644 --- a/packages/cli/tests/bindings-test/src/test_d1.py +++ b/packages/cli/tests/bindings-test/src/test_d1.py @@ -1,4 +1,5 @@ import pytest +from pyodide.ffi import JsException TEST_TABLE = "_test_d1" TEST_TABLE_TYPES = "_test_d1_types" @@ -46,7 +47,7 @@ async def test_insert_and_select_via_run(env): .bind("run_test", "hello") .run() ) - assert insert_result["success"] is True, f"insert failed: {insert_result!r}" + assert insert_result["success"] is True meta = insert_result["meta"] assert meta["changes"] >= 1 assert meta["last_row_id"] > 0 @@ -57,7 +58,7 @@ async def test_insert_and_select_via_run(env): .run() ) rows = select_result["results"] - assert len(rows) == 1, f"expected 1 row, got {len(rows)}" + assert len(rows) == 1 assert rows[0]["name"] == "run_test" assert rows[0]["value"] == "hello" @@ -79,7 +80,7 @@ async def test_all_returns_results(env): ) assert result["success"] is True rows = result["results"] - assert len(rows) >= 1, f"expected >= 1 row, got {len(rows)}" + assert len(rows) >= 1 assert rows[0]["name"] == "all_test" @@ -99,7 +100,7 @@ async def test_first_returns_single_row(env): .first() ) assert row is not None, "first() returned None" - assert isinstance(row, dict), f"expected dict, got {type(row)}: {row}" + assert isinstance(row, dict) assert row["name"] == "first_test" assert row["value"] == "fv" @@ -119,7 +120,7 @@ async def test_first_with_column_name(env): .bind("first_col") .first("value") ) - assert value == "col_val", f"expected 'col_val', got {value!r}" + assert value == "col_val" @pytest.mark.asyncio @@ -132,7 +133,7 @@ async def test_first_on_empty_result(env): .bind("__nonexistent__xyz__") .first() ) - assert row is None, f"expected None, got {row!r}" + assert row is None @pytest.mark.asyncio @@ -150,9 +151,9 @@ async def test_raw_returns_arrays(env): .bind("raw_test") .raw() ) - assert isinstance(rows, list), f"expected list, got {type(rows)}" - assert len(rows) == 1, f"expected 1 row, got {len(rows)}" - assert rows[0] == ["raw_test", "rv"], f"row mismatch: {rows!r}" + assert isinstance(rows, list) + assert len(rows) == 1 + assert rows[0] == ["raw_test", "rv"] @pytest.mark.asyncio @@ -170,9 +171,9 @@ async def test_raw_with_column_names(env): .bind("raw_cols") .raw({"columnNames": True}) ) - assert len(rows) == 2, f"expected header + data, got {len(rows)} rows" - assert rows[0] == ["name", "value"], f"header mismatch: {rows[0]!r}" - assert rows[1] == ["raw_cols", "rc"], f"row mismatch: {rows[1]!r}" + assert len(rows) == 2 + assert rows[0] == ["name", "value"] + assert rows[1] == ["raw_cols", "rc"] @pytest.mark.asyncio @@ -220,8 +221,8 @@ async def test_bind_types(env): .bind(4) .first() ) - assert row3["intval"] == 1, f"True should be 1, got {row3['intval']}" - assert row4["intval"] == 0, f"False should be 0, got {row4['intval']}" + assert row3["intval"] == 1 + assert row4["intval"] == 0 @pytest.mark.asyncio @@ -231,8 +232,8 @@ async def test_exec_create_and_query(env): result = await db.exec( f"CREATE TABLE IF NOT EXISTS {EXEC_TABLE} (id INTEGER PRIMARY KEY, val TEXT)" ) - assert result["count"] >= 1, f"expected count >= 1, got {result['count']}" - assert result["duration"] >= 0, f"expected duration >= 0, got {result['duration']}" + assert result["count"] >= 1 + assert result["duration"] >= 0 @pytest.mark.asyncio @@ -244,9 +245,9 @@ async def test_exec_multiple_statements(env): f"INSERT INTO {EXEC_MULTI_TABLE} (val) VALUES ('a');\n" f"INSERT INTO {EXEC_MULTI_TABLE} (val) VALUES ('b');" ) - assert result["count"] >= 3, f"expected count >= 3, got {result['count']}" + assert result["count"] >= 3 rows = await db.prepare(f"SELECT val FROM {EXEC_MULTI_TABLE} ORDER BY val").raw() - assert rows == [["a"], ["b"]], f"row mismatch: {rows!r}" + assert rows == [["a"], ["b"]] @pytest.mark.asyncio @@ -271,7 +272,7 @@ async def test_batch_multiple_inserts(env): f"SELECT id, val FROM {TEST_TABLE_BATCH} ORDER BY id" ).all() rows = all_rows["results"] - assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" + assert len(rows) == 3 assert [row["val"] for row in rows] == ["batch_a", "batch_b", "batch_c"] @@ -296,7 +297,7 @@ async def test_run_metadata_fields(env): "rows_written", "size_after", ]: - assert key in meta, f"missing {key!r} in meta: {meta!r}" + assert key in meta assert meta["changes"] >= 1 assert meta["changed_db"] is True @@ -323,7 +324,7 @@ async def test_update_row(env): .bind(row_id) .first() ) - assert row["value"] == "new_value", f"expected 'new_value', got {row['value']!r}" + assert row["value"] == "new_value" @pytest.mark.asyncio @@ -346,7 +347,7 @@ async def test_delete_row(env): .bind(row_id) .first() ) - assert row is None, f"row should be deleted, got {row!r}" + assert row is None @pytest.mark.asyncio @@ -373,16 +374,12 @@ async def test_session_bookmark(env): await _ensure_tables(db) session = db.withSession() bookmark_before = session.getBookmark() - assert bookmark_before is None, ( - f"expected None before query, got {bookmark_before!r}" - ) + assert bookmark_before is None await session.prepare("SELECT 1").all() bookmark_after = session.getBookmark() - assert bookmark_after is not None, "expected bookmark after query, got None" - assert isinstance(bookmark_after, str), ( - f"expected string, got {type(bookmark_after)}" - ) - assert len(bookmark_after) > 0, "bookmark should be non-empty" + assert bookmark_after is not None + assert isinstance(bookmark_after, str) + assert len(bookmark_after) > 0 @pytest.mark.asyncio @@ -413,12 +410,8 @@ async def test_session_batch(env): async def test_invalid_sql_raises_error(env): db = env.DB await _cleanup_d1(db) - raised = False - try: + with pytest.raises(JsException, match="syntax error"): await db.prepare("INVALID SQL GIBBERISH").run() - except Exception: - raised = True - assert raised, "expected error on invalid SQL" @pytest.mark.asyncio @@ -436,9 +429,9 @@ async def test_raw_kwargs_column_names(env): .bind("kwargs_raw") .raw(columnNames=True) ) - assert len(rows) == 2, f"expected header + data, got {len(rows)} rows" - assert rows[0] == ["name", "value"], f"header mismatch: {rows[0]!r}" - assert rows[1] == ["kwargs_raw", "kv"], f"row mismatch: {rows[1]!r}" + assert len(rows) == 2 + assert rows[0] == ["name", "value"] + assert rows[1] == ["kwargs_raw", "kv"] @pytest.mark.asyncio @@ -464,9 +457,9 @@ async def test_bind_null(env): .bind(1) .first() ) - assert row["txt"] is None, f"expected None, got {row['txt']!r}" - assert row["num"] is None, f"expected None, got {row['num']!r}" - assert row["intval"] is None, f"expected None, got {row['intval']!r}" + assert row["txt"] is None + assert row["num"] is None + assert row["intval"] is None @pytest.mark.asyncio @@ -484,5 +477,5 @@ async def test_none_options_raw(env): .bind("none_raw") .raw(None) ) - assert len(rows) == 1, f"expected 1 row, got {len(rows)}" - assert rows[0] == ["none_raw", "nr"], f"row mismatch: {rows[0]!r}" + assert len(rows) == 1 + assert rows[0] == ["none_raw", "nr"] diff --git a/packages/cli/tests/bindings-test/src/test_kv.py b/packages/cli/tests/bindings-test/src/test_kv.py index 27a30ae..8c648cd 100644 --- a/packages/cli/tests/bindings-test/src/test_kv.py +++ b/packages/cli/tests/bindings-test/src/test_kv.py @@ -4,17 +4,14 @@ async def _cleanup_kv(kv): - cursor = None + options = {"prefix": "_test:", "limit": 1000} while True: - options = {"prefix": "_test:", "limit": 1000} - if cursor: - options["cursor"] = cursor result = await kv.list(options) for key_entry in result["keys"]: await kv.delete(key_entry["name"]) if result["list_complete"]: break - cursor = result.get("cursor") + options["cursor"] = result.get("cursor") @pytest.mark.asyncio @@ -25,7 +22,7 @@ async def test_put_and_get_text(env): value = "hello from KV" await kv.put(key, value) result = await kv.get(key) - assert result == value, f"expected {value!r}, got {result!r}" + assert result == value @pytest.mark.asyncio @@ -33,7 +30,7 @@ async def test_get_nonexistent(env): kv = env.KV await _cleanup_kv(kv) result = await kv.get("_test:does_not_exist_12345") - assert result is None, f"expected None, got {result!r}" + assert result is None @pytest.mark.asyncio @@ -44,8 +41,8 @@ async def test_put_and_get_json(env): payload = {"message": "hello", "numbers": [1, 2, 3]} await kv.put(key, json.dumps(payload)) result = await kv.get(key, "json") - assert isinstance(result, dict), f"expected dict, got {type(result)}: {result!r}" - assert result == payload, f"json mismatch: {result!r}" + assert isinstance(result, dict) + assert result == payload @pytest.mark.asyncio @@ -68,7 +65,7 @@ async def test_put_empty_value(env): key = "_test:empty_value" await kv.put(key, "") result = await kv.get(key) - assert result == "", f"expected empty string, got {result!r}" + assert result == "" @pytest.mark.asyncio @@ -80,7 +77,7 @@ async def test_delete(env): assert await kv.get(key) == "to be deleted" await kv.delete(key) result = await kv.get(key) - assert result is None, f"expected None after delete, got {result!r}" + assert result is None @pytest.mark.asyncio @@ -100,7 +97,7 @@ async def test_put_with_metadata(env): result = await kv.getWithMetadata(key) assert result["value"] == "metadata test" assert result["metadata"] is not None, "expected metadata" - assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" + assert result["metadata"] == metadata @pytest.mark.asyncio @@ -108,10 +105,8 @@ async def test_get_with_metadata_nonexistent(env): kv = env.KV await _cleanup_kv(kv) result = await kv.getWithMetadata("_test:does_not_exist_meta") - assert result["value"] is None, f"expected None value, got {result['value']!r}" - assert result["metadata"] is None, ( - f"expected None metadata, got {result['metadata']!r}" - ) + assert result["value"] is None + assert result["metadata"] is None @pytest.mark.asyncio @@ -121,7 +116,7 @@ async def test_put_with_expiration_ttl(env): key = "_test:expiration_ttl" await kv.put(key, "expires soon", {"expirationTtl": 60}) result = await kv.get(key) - assert result == "expires soon", f"value mismatch: {result!r}" + assert result == "expires soon" listed = await kv.list({"prefix": key}) matching = [k for k in listed["keys"] if k["name"] == key] assert len(matching) == 1, "key not found in list" @@ -137,7 +132,7 @@ async def test_list_basic(env): result = await kv.list({"prefix": "_test:list_basic:"}) keys = result["keys"] names = [k["name"] for k in keys] - assert len(keys) >= 3, f"expected >= 3 keys, got {len(keys)}" + assert len(keys) >= 3 assert result["list_complete"] for i in range(3): assert f"_test:list_basic:{i}" in names, f"missing key {i}" @@ -152,7 +147,7 @@ async def test_list_with_prefix(env): await kv.put("_test:prefix_b:1", "b1") result = await kv.list({"prefix": "_test:prefix_a:"}) names = [k["name"] for k in result["keys"]] - assert len(names) == 2, f"expected 2 keys, got {len(names)}" + assert len(names) == 2 assert all(n.startswith("_test:prefix_a:") for n in names), ( f"prefix filter failed: {names!r}" ) @@ -166,13 +161,13 @@ async def test_list_with_limit_and_cursor(env): for i in range(5): await kv.put(f"{prefix}{i:03d}", f"val-{i}") page1 = await kv.list({"prefix": prefix, "limit": 2}) - assert len(page1["keys"]) == 2, f"first page: expected 2, got {len(page1['keys'])}" + assert len(page1["keys"]) == 2 assert not page1["list_complete"], "expected list_complete=False" assert page1.get("cursor") is not None, "expected cursor on first page" page2 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) - assert len(page2["keys"]) == 2, f"second page: expected 2, got {len(page2['keys'])}" + assert len(page2["keys"]) == 2 page3 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) - assert len(page3["keys"]) == 1, f"third page: expected 1, got {len(page3['keys'])}" + assert len(page3["keys"]) == 1 assert page3["list_complete"], "expected list_complete=True on last page" @@ -181,7 +176,7 @@ async def test_list_empty_prefix(env): kv = env.KV await _cleanup_kv(kv) result = await kv.list({"prefix": "_test:nonexistent_prefix_xyz:"}) - assert len(result["keys"]) == 0, f"expected 0 keys, got {len(result['keys'])}" + assert len(result["keys"]) == 0 assert result["list_complete"] @@ -194,7 +189,7 @@ async def test_list_with_metadata(env): await kv.put(key, "has metadata", {"metadata": metadata}) result = await kv.list({"prefix": key}) matching = [k for k in result["keys"] if k["name"] == key] - assert len(matching) == 1, f"expected one key, got {len(matching)}" + assert len(matching) == 1 assert matching[0].get("metadata") is not None, "expected metadata in list result" assert matching[0]["metadata"] == metadata, ( f"metadata mismatch: {matching[0]['metadata']!r}" @@ -210,7 +205,7 @@ async def test_get_with_metadata_has_metadata(env): await kv.put(key, "value with meta", {"metadata": metadata}) result = await kv.getWithMetadata(key) assert result["value"] == "value with meta" - assert result["metadata"] == metadata, f"metadata mismatch: {result['metadata']!r}" + assert result["metadata"] == metadata @pytest.mark.asyncio @@ -221,7 +216,7 @@ async def test_get_type_as_options_dict(env): payload = {"x": "hello"} await kv.put(key, json.dumps(payload)) result = await kv.get(key, {"type": "json"}) - assert isinstance(result, dict), f"expected dict, got {type(result)}: {result!r}" + assert isinstance(result, dict) assert result["x"] == "hello" @@ -233,7 +228,7 @@ async def test_get_arraybuffer(env): await kv.put(key, "binary test data") result = await kv.get(key, "arrayBuffer") assert result is not None - assert isinstance(result, memoryview), f"expected memoryview, got {type(result)}" + assert isinstance(result, memoryview) assert len(result) > 0 @@ -244,7 +239,7 @@ async def test_get_multiple_keys(env): await kv.put("_test:multi:a", "val-a") await kv.put("_test:multi:b", "val-b") result = await kv.get(["_test:multi:a", "_test:multi:b", "_test:multi:nonexistent"]) - assert isinstance(result, dict), f"expected dict from multi-get, got {type(result)}" + assert isinstance(result, dict) assert result.get("_test:multi:a") == "val-a" assert result.get("_test:multi:b") == "val-b" assert result.get("_test:multi:nonexistent") is None @@ -257,7 +252,7 @@ async def test_get_multiple_keys_json(env): await kv.put("_test:multi_json:a", json.dumps({"val": "a"})) await kv.put("_test:multi_json:b", json.dumps({"val": "b"})) result = await kv.get(["_test:multi_json:a", "_test:multi_json:b"], "json") - assert isinstance(result, dict), f"expected dict, got {type(result)}" + assert isinstance(result, dict) assert result["_test:multi_json:a"]["val"] == "a" assert result["_test:multi_json:b"]["val"] == "b" @@ -269,7 +264,7 @@ async def test_put_kwargs_expiration_ttl(env): key = "_test:kwargs_ttl" await kv.put(key, "kwargs ttl", expirationTtl=60) result = await kv.get(key) - assert result == "kwargs ttl", f"expected 'kwargs ttl', got {result!r}" + assert result == "kwargs ttl" listed = await kv.list(prefix=key) matching = [k for k in listed["keys"] if k["name"] == key] assert len(matching) == 1 @@ -293,7 +288,7 @@ async def test_none_options_put(env): await _cleanup_kv(kv) await kv.put("_test:none_opts", "value", None) result = await kv.get("_test:none_opts") - assert result == "value", f"expected 'value', got {result!r}" + assert result == "value" @pytest.mark.asyncio diff --git a/packages/cli/tests/bindings-test/src/test_r2.py b/packages/cli/tests/bindings-test/src/test_r2.py index b2209c2..32080d6 100644 --- a/packages/cli/tests/bindings-test/src/test_r2.py +++ b/packages/cli/tests/bindings-test/src/test_r2.py @@ -31,7 +31,7 @@ async def test_put_and_get_text(env): body = await bucket.get(key) assert body is not None, "get returned None" text = await body.text() - assert text == value, f"text mismatch: {text!r}" + assert text == value assert body["bodyUsed"] is True @@ -51,7 +51,7 @@ async def test_put_and_get_json(env): body = await bucket.get(key) assert body is not None, "get returned None" parsed = await body.json() - assert parsed == payload, f"json mismatch: {parsed!r}" + assert parsed == payload @pytest.mark.asyncio @@ -115,7 +115,7 @@ async def test_get_nonexistent(env): bucket = env.BUCKET await _cleanup_r2(bucket) result = await bucket.get("_test/does_not_exist_12345") - assert result is None, f"expected None, got {result!r}" + assert result is None @pytest.mark.asyncio @@ -123,7 +123,7 @@ async def test_head_nonexistent(env): bucket = env.BUCKET await _cleanup_r2(bucket) result = await bucket.head("_test/does_not_exist_12345") - assert result is None, f"expected None, got {result!r}" + assert result is None @pytest.mark.asyncio @@ -148,7 +148,7 @@ async def test_delete_multiple(env): await bucket.delete(keys) for key in keys: result = await bucket.head(key) - assert result is None, f"{key} still exists after batch delete" + assert result is None @pytest.mark.asyncio @@ -160,7 +160,7 @@ async def test_list_basic(env): result = await bucket.list({"prefix": "_test/list_basic/"}) objects = result["objects"] keys = [obj.key for obj in objects] - assert len(objects) >= 3, f"expected >= 3 objects, got {len(objects)}" + assert len(objects) >= 3 for i in range(3): assert f"_test/list_basic/{i}" in keys, f"missing key {i}" @@ -174,7 +174,7 @@ async def test_list_with_prefix(env): await bucket.put("_test/prefix_b/1", "b1") result = await bucket.list({"prefix": "_test/prefix_a/"}) keys = [obj.key for obj in result["objects"]] - assert len(keys) == 2, f"expected 2 objects, got {len(keys)}" + assert len(keys) == 2 assert all(k.startswith("_test/prefix_a/") for k in keys), ( f"prefix filter failed: {keys!r}" ) @@ -215,9 +215,9 @@ async def test_list_with_delimiter(env): result = await bucket.list({"prefix": "_test/delim/", "delimiter": "/"}) object_keys = [obj.key for obj in result["objects"]] prefixes = result["delimitedPrefixes"] - assert "_test/delim/root_file" in object_keys, f"missing root file: {object_keys!r}" - assert "_test/delim/dir1/" in prefixes, f"missing dir1 prefix: {prefixes!r}" - assert "_test/delim/dir2/" in prefixes, f"missing dir2 prefix: {prefixes!r}" + assert "_test/delim/root_file" in object_keys + assert "_test/delim/dir1/" in prefixes + assert "_test/delim/dir2/" in prefixes @pytest.mark.asyncio @@ -240,11 +240,11 @@ async def test_put_empty_body(env): key = "_test/empty_body" obj = await bucket.put(key, None) assert obj is not None, "put returned None" - assert obj.size == 0, f"expected size 0, got {obj.size}" + assert obj.size == 0 body = await bucket.get(key) assert body is not None, "get returned None" text = await body.text() - assert text == "", f"expected empty string, got {text!r}" + assert text == "" @pytest.mark.asyncio @@ -257,7 +257,7 @@ async def test_get_range_offset_length(env): body = await bucket.get(key, {"range": {"offset": 4, "length": 6}}) assert body is not None, "get returned None" text = await body.text() - assert text == "456789", f"range mismatch: {text!r}" + assert text == "456789" @pytest.mark.asyncio @@ -270,7 +270,7 @@ async def test_get_range_suffix(env): body = await bucket.get(key, {"range": {"suffix": 4}}) assert body is not None, "get returned None" text = await body.text() - assert text == "CDEF", f"suffix mismatch: {text!r}" + assert text == "CDEF" @pytest.mark.asyncio @@ -362,7 +362,7 @@ async def test_list_kwargs_prefix(env): await bucket.put("_test/kw_b/1", "b1") result = await bucket.list(prefix="_test/kw_a/") keys = [obj.key for obj in result.objects] - assert len(keys) == 2, f"expected 2, got {len(keys)}" + assert len(keys) == 2 assert all(k.startswith("_test/kw_a/") for k in keys) @@ -375,7 +375,7 @@ async def test_none_options_put(env): body = await bucket.get(key) assert body is not None text = await body.text() - assert text == "value", f"expected 'value', got {text!r}" + assert text == "value" @pytest.mark.asyncio From c099e595ac148444c1911c5e7cadffa75b53eb67 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Mon, 8 Jun 2026 14:46:59 +0900 Subject: [PATCH 10/12] chore: address comments --- packages/runtime-sdk/src/workers/_workers.py | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 577e2b6..2f0c028 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -958,16 +958,16 @@ def __setattr__(self, name, value): self[name] = value -def _normalize_result(obj): +def _replace_jsnull_with_none(obj): """ Recursively converts JS objects to Python objects. """ if obj is jsnull: return None if isinstance(obj, dict): - return JsDict({k: _normalize_result(v) for k, v in obj.items()}) + return JsDict({k: _replace_jsnull_with_none(v) for k, v in obj.items()}) if isinstance(obj, list): - return [_normalize_result(v) for v in obj] + return [_replace_jsnull_with_none(v) for v in obj] return obj @@ -993,11 +993,11 @@ def python_from_rpc(obj: "JsProxy"): result = obj.to_py(default_converter=_python_from_rpc_default_converter) - return _normalize_result(result) + return _replace_jsnull_with_none(result) def _raise_on_disabled_type(value): - if isinstance(value, _RPCWrapper): + if isinstance(value, _BindingWrapper): return if _is_js_instance(value, "RegExp"): @@ -1023,7 +1023,7 @@ def _python_to_rpc_default_converter(obj, convert, cache): if obj is None: return jsnull - if isinstance(obj, _RPCWrapper): + if isinstance(obj, _BindingWrapper): return obj._binding if hasattr(obj, "js_object"): @@ -1063,7 +1063,7 @@ def python_to_rpc(value) -> JsProxy: if value is None: return jsnull - if isinstance(value, _RPCWrapper): + if isinstance(value, _BindingWrapper): return value._binding value = _replace_none_with_jsnull(value) @@ -1080,7 +1080,7 @@ def python_to_rpc(value) -> JsProxy: return result -class _RPCWrapper: +class _BindingWrapper: def __init__(self, binding): self._binding = binding @@ -1124,7 +1124,7 @@ def __getitem__(self, key): return self._convert_result(getattr(self._binding, key)) -class _FetcherWrapper(_RPCWrapper): +class _FetcherWrapper(_BindingWrapper): def fetch(self, *args, **kwargs): return fetch(*args, fetcher=self._binding.fetch, **kwargs) @@ -1243,13 +1243,13 @@ def _getattr_helper(self, name): return _WorkflowBindingWrapper(binding) if _is_js_instance(binding, "KvNamespace"): - return _RPCWrapper(binding) + return _BindingWrapper(binding) if _is_js_instance(binding, "R2Bucket"): - return _RPCWrapper(binding) + return _BindingWrapper(binding) if _is_js_instance(binding, "D1Database"): - return _RPCWrapper(binding) + return _BindingWrapper(binding) return binding From 38bb396a4f2fb34b962d625795c4234fdd097d1a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Mon, 8 Jun 2026 16:01:01 +0900 Subject: [PATCH 11/12] chore: use kwargs passing in bindings test --- .../cli/tests/bindings-test/src/test_d1.py | 12 +-- .../cli/tests/bindings-test/src/test_kv.py | 48 ++++++------ .../cli/tests/bindings-test/src/test_r2.py | 74 ++++++++----------- 3 files changed, 62 insertions(+), 72 deletions(-) diff --git a/packages/cli/tests/bindings-test/src/test_d1.py b/packages/cli/tests/bindings-test/src/test_d1.py index a657fb8..3c54763 100644 --- a/packages/cli/tests/bindings-test/src/test_d1.py +++ b/packages/cli/tests/bindings-test/src/test_d1.py @@ -169,7 +169,7 @@ async def test_raw_with_column_names(env): rows = ( await db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") .bind("raw_cols") - .raw({"columnNames": True}) + .raw(columnNames=True) ) assert len(rows) == 2 assert rows[0] == ["name", "value"] @@ -415,23 +415,23 @@ async def test_invalid_sql_raises_error(env): @pytest.mark.asyncio -async def test_raw_kwargs_column_names(env): +async def test_raw_dict_column_names(env): db = env.DB await _cleanup_d1(db) await _ensure_tables(db) await ( db.prepare(f"INSERT INTO {TEST_TABLE} (name, value) VALUES (?, ?)") - .bind("kwargs_raw", "kv") + .bind("dict_raw", "dr") .run() ) rows = await ( db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") - .bind("kwargs_raw") - .raw(columnNames=True) + .bind("dict_raw") + .raw({"columnNames": True}) ) assert len(rows) == 2 assert rows[0] == ["name", "value"] - assert rows[1] == ["kwargs_raw", "kv"] + assert rows[1] == ["dict_raw", "dr"] @pytest.mark.asyncio diff --git a/packages/cli/tests/bindings-test/src/test_kv.py b/packages/cli/tests/bindings-test/src/test_kv.py index 8c648cd..3f42353 100644 --- a/packages/cli/tests/bindings-test/src/test_kv.py +++ b/packages/cli/tests/bindings-test/src/test_kv.py @@ -93,7 +93,7 @@ async def test_put_with_metadata(env): await _cleanup_kv(kv) key = "_test:metadata" metadata = {"author": "test-suite", "version": "1.0"} - await kv.put(key, "metadata test", {"metadata": metadata}) + await kv.put(key, "metadata test", metadata=metadata) result = await kv.getWithMetadata(key) assert result["value"] == "metadata test" assert result["metadata"] is not None, "expected metadata" @@ -114,10 +114,10 @@ async def test_put_with_expiration_ttl(env): kv = env.KV await _cleanup_kv(kv) key = "_test:expiration_ttl" - await kv.put(key, "expires soon", {"expirationTtl": 60}) + await kv.put(key, "expires soon", expirationTtl=60) result = await kv.get(key) assert result == "expires soon" - listed = await kv.list({"prefix": key}) + listed = await kv.list(prefix=key) matching = [k for k in listed["keys"] if k["name"] == key] assert len(matching) == 1, "key not found in list" assert matching[0].get("expiration") is not None, "expected expiration to be set" @@ -129,7 +129,7 @@ async def test_list_basic(env): await _cleanup_kv(kv) for i in range(3): await kv.put(f"_test:list_basic:{i}", f"val-{i}") - result = await kv.list({"prefix": "_test:list_basic:"}) + result = await kv.list(prefix="_test:list_basic:") keys = result["keys"] names = [k["name"] for k in keys] assert len(keys) >= 3 @@ -145,7 +145,7 @@ async def test_list_with_prefix(env): await kv.put("_test:prefix_a:1", "a1") await kv.put("_test:prefix_a:2", "a2") await kv.put("_test:prefix_b:1", "b1") - result = await kv.list({"prefix": "_test:prefix_a:"}) + result = await kv.list(prefix="_test:prefix_a:") names = [k["name"] for k in result["keys"]] assert len(names) == 2 assert all(n.startswith("_test:prefix_a:") for n in names), ( @@ -160,13 +160,13 @@ async def test_list_with_limit_and_cursor(env): prefix = "_test:paginate:" for i in range(5): await kv.put(f"{prefix}{i:03d}", f"val-{i}") - page1 = await kv.list({"prefix": prefix, "limit": 2}) + page1 = await kv.list(prefix=prefix, limit=2) assert len(page1["keys"]) == 2 assert not page1["list_complete"], "expected list_complete=False" assert page1.get("cursor") is not None, "expected cursor on first page" - page2 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) + page2 = await kv.list(prefix=prefix, limit=2, cursor=page1["cursor"]) assert len(page2["keys"]) == 2 - page3 = await kv.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) + page3 = await kv.list(prefix=prefix, limit=2, cursor=page2["cursor"]) assert len(page3["keys"]) == 1 assert page3["list_complete"], "expected list_complete=True on last page" @@ -175,7 +175,7 @@ async def test_list_with_limit_and_cursor(env): async def test_list_empty_prefix(env): kv = env.KV await _cleanup_kv(kv) - result = await kv.list({"prefix": "_test:nonexistent_prefix_xyz:"}) + result = await kv.list(prefix="_test:nonexistent_prefix_xyz:") assert len(result["keys"]) == 0 assert result["list_complete"] @@ -186,8 +186,8 @@ async def test_list_with_metadata(env): await _cleanup_kv(kv) key = "_test:list_meta" metadata = {"tag": "listed"} - await kv.put(key, "has metadata", {"metadata": metadata}) - result = await kv.list({"prefix": key}) + await kv.put(key, "has metadata", metadata=metadata) + result = await kv.list(prefix=key) matching = [k for k in result["keys"] if k["name"] == key] assert len(matching) == 1 assert matching[0].get("metadata") is not None, "expected metadata in list result" @@ -202,7 +202,7 @@ async def test_get_with_metadata_has_metadata(env): await _cleanup_kv(kv) key = "_test:gwm_meta" metadata = {"env": "test", "version": 2} - await kv.put(key, "value with meta", {"metadata": metadata}) + await kv.put(key, "value with meta", metadata=metadata) result = await kv.getWithMetadata(key) assert result["value"] == "value with meta" assert result["metadata"] == metadata @@ -215,7 +215,7 @@ async def test_get_type_as_options_dict(env): key = "_test:get_type_opts" payload = {"x": "hello"} await kv.put(key, json.dumps(payload)) - result = await kv.get(key, {"type": "json"}) + result = await kv.get(key, type="json") assert isinstance(result, dict) assert result["x"] == "hello" @@ -258,28 +258,28 @@ async def test_get_multiple_keys_json(env): @pytest.mark.asyncio -async def test_put_kwargs_expiration_ttl(env): +async def test_put_dict_expiration_ttl(env): kv = env.KV await _cleanup_kv(kv) - key = "_test:kwargs_ttl" - await kv.put(key, "kwargs ttl", expirationTtl=60) + key = "_test:dict_ttl" + await kv.put(key, "dict ttl", {"expirationTtl": 60}) result = await kv.get(key) - assert result == "kwargs ttl" - listed = await kv.list(prefix=key) + assert result == "dict ttl" + listed = await kv.list({"prefix": key}) matching = [k for k in listed["keys"] if k["name"] == key] assert len(matching) == 1 - assert matching[0].get("expiration") is not None, "expected expiration to be set" + assert matching[0].get("expiration") is not None @pytest.mark.asyncio -async def test_put_kwargs_metadata(env): +async def test_put_dict_metadata(env): kv = env.KV await _cleanup_kv(kv) - key = "_test:kwargs_meta" - await kv.put(key, "kwargs meta", metadata={"source": "kwargs"}) + key = "_test:dict_meta" + await kv.put(key, "dict meta", {"metadata": {"source": "dict"}}) result = await kv.getWithMetadata(key) - assert result["value"] == "kwargs meta" - assert result["metadata"]["source"] == "kwargs" + assert result["value"] == "dict meta" + assert result["metadata"]["source"] == "dict" @pytest.mark.asyncio diff --git a/packages/cli/tests/bindings-test/src/test_r2.py b/packages/cli/tests/bindings-test/src/test_r2.py index 32080d6..f4bbdc5 100644 --- a/packages/cli/tests/bindings-test/src/test_r2.py +++ b/packages/cli/tests/bindings-test/src/test_r2.py @@ -44,9 +44,7 @@ async def test_put_and_get_json(env): await bucket.put( key, json.dumps(payload), - { - "httpMetadata": {"contentType": "application/json"}, - }, + httpMetadata={"contentType": "application/json"}, ) body = await bucket.get(key) assert body is not None, "get returned None" @@ -62,13 +60,11 @@ async def test_put_with_http_metadata(env): await bucket.put( key, "metadata test", - { - "httpMetadata": { - "contentType": "text/plain", - "contentLanguage": "en-US", - "contentDisposition": "inline", - "cacheControl": "max-age=3600", - }, + httpMetadata={ + "contentType": "text/plain", + "contentLanguage": "en-US", + "contentDisposition": "inline", + "cacheControl": "max-age=3600", }, ) head = await bucket.head(key) @@ -86,7 +82,7 @@ async def test_put_with_custom_metadata(env): await _cleanup_r2(bucket) key = "_test/custom_meta" custom = {"author": "test-suite", "version": "1.0"} - await bucket.put(key, "custom metadata test", {"customMetadata": custom}) + await bucket.put(key, "custom metadata test", customMetadata=custom) head = await bucket.head(key) assert head is not None, "head returned None" assert head["customMetadata"] == custom, ( @@ -157,7 +153,7 @@ async def test_list_basic(env): await _cleanup_r2(bucket) for i in range(3): await bucket.put(f"_test/list_basic/{i}", f"val-{i}") - result = await bucket.list({"prefix": "_test/list_basic/"}) + result = await bucket.list(prefix="_test/list_basic/") objects = result["objects"] keys = [obj.key for obj in objects] assert len(objects) >= 3 @@ -172,7 +168,7 @@ async def test_list_with_prefix(env): await bucket.put("_test/prefix_a/1", "a1") await bucket.put("_test/prefix_a/2", "a2") await bucket.put("_test/prefix_b/1", "b1") - result = await bucket.list({"prefix": "_test/prefix_a/"}) + result = await bucket.list(prefix="_test/prefix_a/") keys = [obj.key for obj in result["objects"]] assert len(keys) == 2 assert all(k.startswith("_test/prefix_a/") for k in keys), ( @@ -187,17 +183,15 @@ async def test_list_with_limit_and_cursor(env): prefix = "_test/paginate/" for i in range(5): await bucket.put(f"{prefix}{i:03d}", f"val-{i}") - page1 = await bucket.list({"prefix": prefix, "limit": 2}) - assert len(page1["objects"]) == 2, ( - f"first page: expected 2, got {len(page1['objects'])}" - ) + page1 = await bucket.list(prefix=prefix, limit=2) + objects1 = list(page1.objects) + assert len(objects1) == 2 assert page1["truncated"], "expected truncated=True" assert page1["cursor"] is not None, "expected cursor" - page2 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page1["cursor"]}) - assert len(page2["objects"]) == 2, ( - f"second page: expected 2, got {len(page2['objects'])}" - ) - page3 = await bucket.list({"prefix": prefix, "limit": 2, "cursor": page2["cursor"]}) + page2 = await bucket.list(prefix=prefix, limit=2, cursor=page1["cursor"]) + objects2 = list(page2["objects"]) + assert len(objects2) == 2 + page3 = await bucket.list(prefix=prefix, limit=2, cursor=page2["cursor"]) assert len(page3["objects"]) == 1, ( f"third page: expected 1, got {len(page3['objects'])}" ) @@ -212,7 +206,7 @@ async def test_list_with_delimiter(env): await bucket.put("_test/delim/dir1/file2", "f2") await bucket.put("_test/delim/dir2/file1", "f1") await bucket.put("_test/delim/root_file", "rf") - result = await bucket.list({"prefix": "_test/delim/", "delimiter": "/"}) + result = await bucket.list(prefix="_test/delim/", delimiter="/") object_keys = [obj.key for obj in result["objects"]] prefixes = result["delimitedPrefixes"] assert "_test/delim/root_file" in object_keys @@ -254,7 +248,7 @@ async def test_get_range_offset_length(env): key = "_test/range_test" content = "0123456789ABCDEF" await bucket.put(key, content) - body = await bucket.get(key, {"range": {"offset": 4, "length": 6}}) + body = await bucket.get(key, range={"offset": 4, "length": 6}) assert body is not None, "get returned None" text = await body.text() assert text == "456789" @@ -267,7 +261,7 @@ async def test_get_range_suffix(env): key = "_test/range_suffix" content = "0123456789ABCDEF" await bucket.put(key, content) - body = await bucket.get(key, {"range": {"suffix": 4}}) + body = await bucket.get(key, range={"suffix": 4}) assert body is not None, "get returned None" text = await body.text() assert text == "CDEF" @@ -282,10 +276,8 @@ async def test_r2object_properties(env): obj = await bucket.put( key, content, - { - "httpMetadata": {"contentType": "text/plain"}, - "customMetadata": {"foo": "bar"}, - }, + httpMetadata={"contentType": "text/plain"}, + customMetadata={"foo": "bar"}, ) assert obj["key"] == key assert obj["size"] == len(content) @@ -306,9 +298,7 @@ async def test_multipart_upload(env): key = "_test/multipart" upload = await bucket.createMultipartUpload( key, - { - "customMetadata": {"uploadType": "multipart_test"}, - }, + customMetadata={"uploadType": "multipart_test"}, ) assert upload["key"] == key assert isinstance(upload["uploadId"], str) and upload["uploadId"] @@ -343,27 +333,27 @@ async def test_multipart_abort(env): @pytest.mark.asyncio -async def test_put_kwargs_custom_metadata(env): +async def test_put_dict_custom_metadata(env): bucket = env.BUCKET await _cleanup_r2(bucket) - key = "_test/kwargs_meta" - await bucket.put(key, "kwargs test", customMetadata={"source": "kwargs"}) + key = "_test/dict_meta" + await bucket.put(key, "dict test", {"customMetadata": {"source": "dict"}}) head = await bucket.head(key) assert head is not None - assert head.customMetadata == {"source": "kwargs"} + assert head.customMetadata == {"source": "dict"} @pytest.mark.asyncio -async def test_list_kwargs_prefix(env): +async def test_list_dict_prefix(env): bucket = env.BUCKET await _cleanup_r2(bucket) - await bucket.put("_test/kw_a/1", "a1") - await bucket.put("_test/kw_a/2", "a2") - await bucket.put("_test/kw_b/1", "b1") - result = await bucket.list(prefix="_test/kw_a/") + await bucket.put("_test/dict_a/1", "a1") + await bucket.put("_test/dict_a/2", "a2") + await bucket.put("_test/dict_b/1", "b1") + result = await bucket.list({"prefix": "_test/dict_a/"}) keys = [obj.key for obj in result.objects] assert len(keys) == 2 - assert all(k.startswith("_test/kw_a/") for k in keys) + assert all(k.startswith("_test/dict_a/") for k in keys) @pytest.mark.asyncio From 7350eb0571c156d171eaa6a54d57622423694b30 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 9 Jun 2026 17:53:29 +0900 Subject: [PATCH 12/12] feat(runtime-sdk): revise type conversion for Durable Object binding - Parameters passed to the `ctx.storage` of Durable Object binding are now converted to proper JS Objects - Function-type parameters passed to the bindings are now wrapped with `create_proxy` to prevent them from garbage collected --- .../cli/tests/bindings-test/src/test_do.py | 197 ++++++++++++++++ .../cli/tests/bindings-test/src/worker.py | 3 + .../src/worker_durable_object.py | 213 ++++++++++++++++++ .../cli/tests/bindings-test/wrangler.jsonc | 8 + packages/runtime-sdk/src/workers/_workers.py | 14 +- 5 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tests/bindings-test/src/test_do.py create mode 100644 packages/cli/tests/bindings-test/src/worker_durable_object.py diff --git a/packages/cli/tests/bindings-test/src/test_do.py b/packages/cli/tests/bindings-test/src/test_do.py new file mode 100644 index 0000000..414d5f0 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/test_do.py @@ -0,0 +1,197 @@ +import pytest + + +async def _get_stub(env, name="test"): + ns = env.TEST_DO + id = ns.idFromName(name) + return ns.get(id) + + +@pytest.mark.asyncio +async def test_storage_put_and_get(env): + stub = await _get_stub(env) + await stub.test_storage_put_and_get() + + +@pytest.mark.asyncio +async def test_storage_get_nonexistent(env): + stub = await _get_stub(env) + await stub.test_storage_get_nonexistent() + + +@pytest.mark.asyncio +async def test_storage_put_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_put_multiple() + + +@pytest.mark.asyncio +async def test_storage_get_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_get_multiple() + + +@pytest.mark.asyncio +async def test_storage_delete(env): + stub = await _get_stub(env) + await stub.test_storage_delete() + + +@pytest.mark.asyncio +async def test_storage_delete_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_delete_multiple() + + +@pytest.mark.asyncio +async def test_storage_list(env): + stub = await _get_stub(env) + await stub.test_storage_list() + + +@pytest.mark.asyncio +async def test_storage_list_with_options(env): + stub = await _get_stub(env) + await stub.test_storage_list_with_options() + + +@pytest.mark.asyncio +async def test_storage_delete_all(env): + stub = await _get_stub(env) + await stub.test_storage_delete_all() + + +@pytest.mark.asyncio +async def test_storage_value_types(env): + stub = await _get_stub(env) + await stub.test_storage_value_types() + + +@pytest.mark.asyncio +async def test_sql_exec_and_query(env): + stub = await _get_stub(env) + await stub.test_sql_exec_and_query() + + +@pytest.mark.asyncio +async def test_sql_cursor_one(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_one() + + +@pytest.mark.asyncio +async def test_sql_cursor_column_names(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_column_names() + + +@pytest.mark.asyncio +async def test_sql_cursor_rows_read_written(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_rows_read_written() + + +@pytest.mark.asyncio +async def test_sql_database_size(env): + stub = await _get_stub(env) + await stub.test_sql_database_size() + + +@pytest.mark.asyncio +async def test_alarm_set_get_delete(env): + stub = await _get_stub(env) + await stub.test_alarm_set_get_delete() + + +@pytest.mark.asyncio +async def test_transaction(env): + stub = await _get_stub(env) + await stub.test_transaction() + + +@pytest.mark.asyncio +async def test_ctx_id(env): + stub = await _get_stub(env) + await stub.test_ctx_id() + + +@pytest.mark.asyncio +async def test_namespace_id_from_name(env): + ns = env.TEST_DO + id1 = ns.idFromName("deterministic") + id2 = ns.idFromName("deterministic") + assert id1.toString() == id2.toString(), "idFromName should be deterministic" + assert id1.name == "deterministic" + + +@pytest.mark.asyncio +async def test_namespace_new_unique_id(env): + ns = env.TEST_DO + id1 = ns.newUniqueId() + id2 = ns.newUniqueId() + assert id1.toString() != id2.toString(), "newUniqueId should produce unique IDs" + assert len(id1.toString()) == 64, f"expected 64-char hex, got {len(id1.toString())}" + + +@pytest.mark.asyncio +async def test_namespace_id_from_string(env): + ns = env.TEST_DO + original = ns.idFromName("roundtrip") + hex_str = original.toString() + restored = ns.idFromString(hex_str) + assert original.toString() == restored.toString(), "idFromString roundtrip failed" + + +@pytest.mark.asyncio +async def test_rpc_echo(env): + stub = await _get_stub(env) + assert await stub.test_rpc_echo("hello") == "hello" + assert await stub.test_rpc_echo(42) == 42 + assert await stub.test_rpc_echo(True) is True + + +@pytest.mark.asyncio +async def test_rpc_dict(env): + stub = await _get_stub(env) + result = await stub.test_rpc_dict({"key": "value"}) + assert result["received"]["key"] == "value" + assert result["added"] is True + + +@pytest.mark.asyncio +async def test_stub_id(env): + ns = env.TEST_DO + id = ns.idFromName("stub_test") + stub = ns.get(id) + assert stub.id.toString() == id.toString() + assert stub.name == "stub_test" + + +@pytest.mark.asyncio +async def test_fetch(env): + stub = await _get_stub(env, "fetch_test") + resp = await stub.fetch("http://fake-host/ping") + text = await resp.text() + assert text == "pong from DO", f"expected 'pong from DO', got {text!r}" + + +@pytest.mark.asyncio +async def test_block_concurrency_while(env): + stub = await _get_stub(env) + await stub.test_block_concurrency_while() + + +@pytest.mark.asyncio +async def test_storage_sync(env): + stub = await _get_stub(env) + await stub.test_storage_sync() + + +@pytest.mark.asyncio +async def test_id_equals(env): + ns = env.TEST_DO + id1 = ns.idFromName("equal_test") + id2 = ns.idFromName("equal_test") + id3 = ns.idFromName("different") + assert id1.equals(id2), "same name should produce equal IDs" + assert not id1.equals(id3), "different names should produce different IDs" diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index 96a2c21..0adb546 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -14,6 +14,9 @@ import pytest from pyodide.webloop import WebLoop +from worker_durable_object import ( + TestDurableObject, # noqa: F401 - import to trigger side effect of registering the Durable Object +) from workers import Response, WorkerEntrypoint diff --git a/packages/cli/tests/bindings-test/src/worker_durable_object.py b/packages/cli/tests/bindings-test/src/worker_durable_object.py new file mode 100644 index 0000000..d1e81e9 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/worker_durable_object.py @@ -0,0 +1,213 @@ +from workers import DurableObject + + +class TestDurableObject(DurableObject): + def __init__(self, ctx, env): + super().__init__(ctx, env) + + async def fetch(self, request): + from urllib.parse import urlparse + + path = urlparse(request.url).path + if path == "/ping": + from workers import Response + + return Response("pong from DO") + from workers import Response + + return Response("not found", status=404) + + async def test_storage_put_and_get(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("key1", "value1") + result = await self.ctx.storage.get("key1") + assert result == "value1", f"expected 'value1', got {result!r}" + + async def test_storage_get_nonexistent(self): + await self.ctx.storage.deleteAll() + result = await self.ctx.storage.get("missing") + assert result is None, f"expected None, got {result!r}" + + async def test_storage_put_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"a": 1, "b": 2, "c": 3}) + a = await self.ctx.storage.get("a") + b = await self.ctx.storage.get("b") + c = await self.ctx.storage.get("c") + assert a == 1 and b == 2 and c == 3, f"got a={a!r}, b={b!r}, c={c!r}" + + async def test_storage_get_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"a": 1, "b": 2}) + result = await self.ctx.storage.get(["a", "b", "missing"]) + assert result.get("a") == 1 + assert result.get("b") == 2 + + async def test_storage_delete(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("to_delete", "gone") + deleted = await self.ctx.storage.delete("to_delete") + assert deleted is True, f"expected True, got {deleted!r}" + result = await self.ctx.storage.get("to_delete") + assert result is None or repr(result) == "undefined", ( + "expected undefined after delete" + ) + + async def test_storage_delete_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"d1": 1, "d2": 2, "d3": 3}) + count = await self.ctx.storage.delete(["d1", "d2"]) + assert count == 2, f"expected 2, got {count!r}" + + async def test_storage_list(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"list:a": 1, "list:b": 2, "list:c": 3, "other": 99}) + result = await self.ctx.storage.list({"prefix": "list:"}) + assert len(result) == 3, f"expected 3 entries, got {len(result)}" + assert result["list:a"] == 1 + assert result["list:b"] == 2 + + async def test_storage_list_with_options(self): + await self.ctx.storage.deleteAll() + for i in range(5): + await self.ctx.storage.put(f"item:{i:03d}", i) + result = await self.ctx.storage.list({"prefix": "item:", "limit": 2}) + assert len(result) == 2, f"expected 2 entries, got {len(result)}" + + async def test_storage_delete_all(self): + await self.ctx.storage.put("before_clear", "exists") + await self.ctx.storage.deleteAll() + result = await self.ctx.storage.get("before_clear") + assert result is None or repr(result) == "undefined", ( + "expected undefined after deleteAll" + ) + + async def test_sql_exec_and_query(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_sql") + self.ctx.storage.sql.exec( + "CREATE TABLE test_sql (id INTEGER PRIMARY KEY, val TEXT)" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_sql (id, val) VALUES (?, ?)", 1, "hello" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_sql (id, val) VALUES (?, ?)", 2, "world" + ) + rows = self.ctx.storage.sql.exec( + "SELECT id, val FROM test_sql ORDER BY id" + ).toArray() + assert len(rows) == 2, f"expected 2 rows, got {len(rows)}" + assert rows[0]["id"] == 1 and rows[0]["val"] == "hello" + assert rows[1]["id"] == 2 and rows[1]["val"] == "world" + self.ctx.storage.sql.exec("DROP TABLE test_sql") + + async def test_sql_cursor_one(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_one") + self.ctx.storage.sql.exec( + "CREATE TABLE test_one (id INTEGER PRIMARY KEY, val TEXT)" + ) + self.ctx.storage.sql.exec("INSERT INTO test_one VALUES (1, 'only')") + row = self.ctx.storage.sql.exec("SELECT val FROM test_one").one() + assert row["val"] == "only", f"expected 'only', got {row!r}" + self.ctx.storage.sql.exec("DROP TABLE test_one") + + async def test_sql_cursor_column_names(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_cols") + self.ctx.storage.sql.exec("CREATE TABLE test_cols (foo INTEGER, bar TEXT)") + self.ctx.storage.sql.exec("INSERT INTO test_cols VALUES (1, 'a')") + cursor = self.ctx.storage.sql.exec("SELECT foo, bar FROM test_cols") + cols = list(cursor.columnNames) + cursor.toArray() + del cursor # free the cursor otherwise we get Error: database table is locked: SQLITE_LOCKED + assert cols == ["foo", "bar"], f"expected ['foo', 'bar'], got {cols}" + self.ctx.storage.sql.exec("DROP TABLE test_cols") + + async def test_sql_cursor_rows_read_written(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_metrics") + self.ctx.storage.sql.exec("CREATE TABLE test_metrics (id INTEGER PRIMARY KEY)") + write_cursor = self.ctx.storage.sql.exec("INSERT INTO test_metrics VALUES (1)") + write_cursor.toArray() + rows_written = write_cursor.rowsWritten + del write_cursor + assert rows_written >= 1, f"expected rowsWritten >= 1, got {rows_written}" + read_cursor = self.ctx.storage.sql.exec("SELECT * FROM test_metrics") + read_cursor.toArray() + rows_read = read_cursor.rowsRead + del read_cursor # free the cursor otherwise we get Error: database table is locked: SQLITE_LOCKED + assert rows_read >= 1, f"expected rowsRead >= 1, got {rows_read}" + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_metrics") + + async def test_sql_database_size(self): + size = self.ctx.storage.sql.databaseSize + assert isinstance(size, int | float) and size >= 0, ( + f"expected non-negative number, got {size!r}" + ) + + async def test_alarm_set_get_delete(self): + await self.ctx.storage.deleteAlarm() + alarm_before = await self.ctx.storage.getAlarm() + assert alarm_before is None, f"expected no alarm, got {alarm_before!r}" + from datetime import datetime, timedelta + + future_time = datetime.now() + timedelta(minutes=1) + await self.ctx.storage.setAlarm(future_time) + alarm_after = await self.ctx.storage.getAlarm() + assert alarm_after is not None, f"expected alarm time, got {alarm_after!r}" + await self.ctx.storage.deleteAlarm() + alarm_deleted = await self.ctx.storage.getAlarm() + assert alarm_deleted is None, "expected no alarm after delete" + + async def test_transaction(self): + await self.ctx.storage.deleteAll() + + async def txn_body(txn): + await txn.put("txn_key", "txn_value") + val = await txn.get("txn_key") + return val + + result = await self.ctx.storage.transaction(txn_body) + assert result == "txn_value", f"expected 'txn_value', got {result!r}" + persisted = await self.ctx.storage.get("txn_key") + assert persisted == "txn_value", ( + f"expected persisted 'txn_value', got {persisted!r}" + ) + + async def test_ctx_id(self): + id_str = self.ctx.id.toString() + assert isinstance(id_str, str) and len(id_str) == 64, ( + f"expected 64-char hex, got {id_str!r}" + ) + assert self.ctx.id.name is not None, "expected id.name for named DO" + + async def test_block_concurrency_while(self): + async def init(): + await self.ctx.storage.put("bcw_key", "bcw_value") + return 42 + + result = await self.ctx.blockConcurrencyWhile(init) + assert result == 42, f"expected 42, got {result!r}" + val = await self.ctx.storage.get("bcw_key") + assert val == "bcw_value", f"expected 'bcw_value', got {val!r}" + + async def test_storage_sync(self): + await self.ctx.storage.put("sync_key", "sync_value") + await self.ctx.storage.sync() + result = await self.ctx.storage.get("sync_key") + assert result == "sync_value", f"expected 'sync_value', got {result!r}" + + async def test_rpc_echo(self, value): + return value + + async def test_rpc_dict(self, data): + return {"received": data, "added": True} + + async def test_storage_value_types(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("str", "hello") + await self.ctx.storage.put("int", 42) + await self.ctx.storage.put("float", 3.14) + await self.ctx.storage.put("bool", True) + assert await self.ctx.storage.get("str") == "hello" + assert await self.ctx.storage.get("int") == 42 + assert abs(await self.ctx.storage.get("float") - 3.14) < 0.001 + assert await self.ctx.storage.get("bool") is True diff --git a/packages/cli/tests/bindings-test/wrangler.jsonc b/packages/cli/tests/bindings-test/wrangler.jsonc index 87fa084..bcbe05a 100644 --- a/packages/cli/tests/bindings-test/wrangler.jsonc +++ b/packages/cli/tests/bindings-test/wrangler.jsonc @@ -15,5 +15,13 @@ "database_id": "00000000-0000-0000-0000-000000000000", "database_name": "test-db" } + ], + "durable_objects": { + "bindings": [ + { "name": "TEST_DO", "class_name": "TestDurableObject" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] } ] } diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 2f0c028..5e35f36 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -17,7 +17,6 @@ from contextlib import ExitStack, contextmanager from enum import StrEnum from http import HTTPMethod, HTTPStatus -from types import LambdaType from typing import TYPE_CHECKING, Any, Never, Protocol, TypedDict, Unpack import _cloudflare_compat_flags @@ -1000,10 +999,13 @@ def _raise_on_disabled_type(value): if isinstance(value, _BindingWrapper): return + if callable(value) and not isinstance(value, type): + return + if _is_js_instance(value, "RegExp"): raise TypeError(f"{value.constructor.name} cannot be sent over RPC.") - if isinstance(value, (tuple, bytearray, LambdaType)): + if isinstance(value, (tuple, bytearray)): raise TypeError(f"{type(value)} cannot be sent over RPC.") if inspect.isawaitable(value): @@ -1036,6 +1038,11 @@ def _python_to_rpc_default_converter(obj, convert, cache): if isinstance(obj, Exception): return js.Error.new(str(obj)) + if callable(obj) and not isinstance(obj, type): + # Wrap function with create_proxy so that + # it doesn't get garbage collected + return create_proxy(obj) + _raise_on_disabled_type(obj) return obj @@ -1158,6 +1165,9 @@ def __init__(self, ctx: "DurableObjectState"): def __getattr__(self, name: str): result = getattr(self._ctx, name) + if _is_js_instance(result, "DurableObjectStorage"): + # durable_object.ctx.storage + result = _BindingWrapper(result) setattr(self, name, result) return result