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/kv_test.py b/packages/cli/tests/bindings-test/src/kv_test.py deleted file mode 100644 index d37d4f2..0000000 --- a/packages/cli/tests/bindings-test/src/kv_test.py +++ /dev/null @@ -1,17 +0,0 @@ -async def _cleanup_kv(kv): - result = await kv.list() - for item in result.keys: - await kv.delete(item.name) - - -async def test_put_and_get(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}" - - -KV_TESTS = { - "put_and_get": test_put_and_get, -} diff --git a/packages/cli/tests/bindings-test/src/test_d1.py b/packages/cli/tests/bindings-test/src/test_d1.py new file mode 100644 index 0000000..3c54763 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/test_d1.py @@ -0,0 +1,481 @@ +import pytest +from pyodide.ffi import JsException + +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)" + ) + + +@pytest.mark.asyncio +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 + 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 + assert rows[0]["name"] == "run_test" + assert rows[0]["value"] == "hello" + + +@pytest.mark.asyncio +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 + assert rows[0]["name"] == "all_test" + + +@pytest.mark.asyncio +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) + assert row["name"] == "first_test" + assert row["value"] == "fv" + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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) + assert len(rows) == 1 + assert rows[0] == ["raw_test", "rv"] + + +@pytest.mark.asyncio +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 + assert rows[0] == ["name", "value"] + assert rows[1] == ["raw_cols", "rc"] + + +@pytest.mark.asyncio +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(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() + ) + + 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 + assert row4["intval"] == 0 + + +@pytest.mark.asyncio +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 + assert result["duration"] >= 0 + + +@pytest.mark.asyncio +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 + rows = await db.prepare(f"SELECT val FROM {EXEC_MULTI_TABLE} ORDER BY val").raw() + assert rows == [["a"], ["b"]] + + +@pytest.mark.asyncio +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 + 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) + 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 + assert meta["changes"] >= 1 + assert meta["changed_db"] is True + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + await session.prepare("SELECT 1").all() + bookmark_after = session.getBookmark() + assert bookmark_after is not None + assert isinstance(bookmark_after, str) + assert len(bookmark_after) > 0 + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +async def test_invalid_sql_raises_error(env): + db = env.DB + await _cleanup_d1(db) + with pytest.raises(JsException, match="syntax error"): + await db.prepare("INVALID SQL GIBBERISH").run() + + +@pytest.mark.asyncio +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("dict_raw", "dr") + .run() + ) + rows = await ( + db.prepare(f"SELECT name, value FROM {TEST_TABLE} WHERE name = ? LIMIT 1") + .bind("dict_raw") + .raw({"columnNames": True}) + ) + assert len(rows) == 2 + assert rows[0] == ["name", "value"] + assert rows[1] == ["dict_raw", "dr"] + + +@pytest.mark.asyncio +async def test_bind_null(env): + import sys + + if sys.version_info < (3, 13): + pytest.skip("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 + assert row["num"] is None + assert row["intval"] is None + + +@pytest.mark.asyncio +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 + assert rows[0] == ["none_raw", "nr"] 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/test_kv.py b/packages/cli/tests/bindings-test/src/test_kv.py new file mode 100644 index 0000000..3f42353 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/test_kv.py @@ -0,0 +1,300 @@ +import json + +import pytest + + +async def _cleanup_kv(kv): + options = {"prefix": "_test:", "limit": 1000} + while True: + result = await kv.list(options) + for key_entry in result["keys"]: + await kv.delete(key_entry["name"]) + if result["list_complete"]: + break + options["cursor"] = result.get("cursor") + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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) + assert result == payload + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 == "" + + +@pytest.mark.asyncio +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 + + +@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) + 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 + + +@pytest.mark.asyncio +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 + assert result["metadata"] is None + + +@pytest.mark.asyncio +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" + 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" + + +@pytest.mark.asyncio +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 + assert result["list_complete"] + for i in range(3): + 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) + 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 + assert all(n.startswith("_test:prefix_a:") for n in names), ( + f"prefix filter failed: {names!r}" + ) + + +@pytest.mark.asyncio +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 + 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 + 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" + + +@pytest.mark.asyncio +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 + assert result["list_complete"] + + +@pytest.mark.asyncio +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 + 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}" + ) + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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) + assert result["x"] == "hello" + + +@pytest.mark.asyncio +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) + assert len(result) > 0 + + +@pytest.mark.asyncio +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) + assert result.get("_test:multi:a") == "val-a" + assert result.get("_test:multi:b") == "val-b" + 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) + 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) + assert result["_test:multi_json:a"]["val"] == "a" + assert result["_test:multi_json:b"]["val"] == "b" + + +@pytest.mark.asyncio +async def test_put_dict_expiration_ttl(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:dict_ttl" + await kv.put(key, "dict ttl", {"expirationTtl": 60}) + result = await kv.get(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 + + +@pytest.mark.asyncio +async def test_put_dict_metadata(env): + kv = env.KV + await _cleanup_kv(kv) + key = "_test:dict_meta" + await kv.put(key, "dict meta", {"metadata": {"source": "dict"}}) + result = await kv.getWithMetadata(key) + assert result["value"] == "dict meta" + assert result["metadata"]["source"] == "dict" + + +@pytest.mark.asyncio +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" + + +@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 diff --git a/packages/cli/tests/bindings-test/src/test_r2.py b/packages/cli/tests/bindings-test/src/test_r2.py new file mode 100644 index 0000000..f4bbdc5 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/test_r2.py @@ -0,0 +1,377 @@ +import json + +import pytest + + +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 + + +@pytest.mark.asyncio +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 + assert body["bodyUsed"] is True + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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}" + ) + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 + for i in range(3): + 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) + 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 + assert all(k.startswith("_test/prefix_a/") for k in keys), ( + f"prefix filter failed: {keys!r}" + ) + + +@pytest.mark.asyncio +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) + 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"]) + 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'])}" + ) + 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) + 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 + assert "_test/delim/dir1/" in prefixes + assert "_test/delim/dir2/" in prefixes + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + body = await bucket.get(key) + assert body is not None, "get returned None" + text = await body.text() + assert text == "" + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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"} + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +async def test_put_dict_custom_metadata(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + 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": "dict"} + + +@pytest.mark.asyncio +async def test_list_dict_prefix(env): + bucket = env.BUCKET + await _cleanup_r2(bucket) + 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/dict_a/") for k in keys) + + +@pytest.mark.asyncio +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" + + +@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 diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index b83d3ae..0adb546 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -1,11 +1,99 @@ -import traceback +"""Bindings test worker. -from kv_test import KV_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 worker_durable_object import ( + TestDurableObject, # noqa: F401 - import to trigger side effect of registering the Durable Object +) from workers import Response, WorkerEntrypoint -ALL_TESTS = { - "kv": KV_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): @@ -16,32 +104,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 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) + 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/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 c660b32..bcbe05a 100644 --- a/packages/cli/tests/bindings-test/wrangler.jsonc +++ b/packages/cli/tests/bindings-test/wrangler.jsonc @@ -5,5 +5,23 @@ "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" + } + ], + "durable_objects": { + "bindings": [ + { "name": "TEST_DO", "class_name": "TestDurableObject" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] } ] } diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 0a9e126..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: @@ -141,7 +146,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', '')}") @@ -159,9 +166,31 @@ def binding_suite(suite: str, tests: list[str]) -> type: ) -TestKV = binding_suite( - "kv", - [ - "put_and_get", - ], -) +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 diff --git a/packages/cli/tests/workerd-test/python-rpc/worker.js b/packages/cli/tests/workerd-test/python-rpc/worker.js index b50ff10..24d0b52 100644 --- a/packages/cli/tests/workerd-test/python-rpc/worker.js +++ b/packages/cli/tests/workerd-test/python-rpc/worker.js @@ -34,19 +34,32 @@ export default { 1, 'test', [1, 2, 3], - new Map([['key', 42]]), 42, 1.2345, false, true, - undefined, ]) { const response = await env.PythonRpc.identity(val); 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 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 7505979..7a6d007 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 @@ -916,6 +915,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 @@ -947,6 +949,41 @@ 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): + # 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 _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: _replace_jsnull_with_none(v) for k, v in obj.items()}) + if isinstance(obj, list): + return [_replace_jsnull_with_none(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 @@ -956,6 +993,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 @@ -966,14 +1006,20 @@ def python_from_rpc(obj: "JsProxy"): result = obj.to_py(default_converter=_python_from_rpc_default_converter) - return result + return _replace_jsnull_with_none(result) 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): @@ -991,7 +1037,10 @@ 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, _BindingWrapper): + return obj._binding if hasattr(obj, "js_object"): return obj.js_object @@ -1003,11 +1052,26 @@ 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 +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 @@ -1017,37 +1081,58 @@ def python_to_rpc(value) -> JsProxy: it does not support serializing all Python object types. """ + if value is None: + return jsnull + + if isinstance(value, _BindingWrapper): + 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) 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 _BindingWrapper: def __init__(self, binding): self._binding = 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 + 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 @@ -1056,6 +1141,11 @@ def __getattr__(self, name): setattr(self, name, result) return result + def __getitem__(self, key): + return self._convert_result(getattr(self._binding, key)) + + +class _FetcherWrapper(_BindingWrapper): def fetch(self, *args, **kwargs): return fetch(*args, fetcher=self._binding.fetch, **kwargs) @@ -1089,6 +1179,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 @@ -1173,7 +1266,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 _BindingWrapper(binding) + + if _is_js_instance(binding, "R2Bucket"): + return _BindingWrapper(binding) + + if _is_js_instance(binding, "D1Database"): + return _BindingWrapper(binding) + return binding def __getattr__(self, name):