From 8dca0e627ae0883e1a8b6d2e574fb71f0c3ae95b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 01:16:21 +0000 Subject: [PATCH] move test suite to in-source tests Use tryke's `__TRYKE_TESTING__` guard to embed tests alongside the code they exercise. Tests now live in database.py, backends/sqlite.py, and backends/memory.py; the tests/ directory is removed. Upgrades tryke to 0.0.18 for `tryke_guard`, and drops `from __future__ import annotations` from database.py so FastAPI dependency resolution sees the in-source closure vars. https://claude.ai/code/session_016AMmfnWUYedYcg8y8ucERN --- pyproject.toml | 2 +- src/sapling/backends/memory.py | 33 +++++ src/sapling/backends/sqlite.py | 50 +++++++ src/sapling/database.py | 178 +++++++++++++++++++++++-- tests/__init__.py | 0 tests/test_sapling.py | 231 --------------------------------- uv.lock | 4 +- 7 files changed, 252 insertions(+), 246 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/test_sapling.py diff --git a/pyproject.toml b/pyproject.toml index 79cc9a6..07b1c35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,8 @@ indent-width = 4 target-version = "py312" [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["ANN201", "PLR2004", "PT028"] "src/sapling/backends/sqlite.py" = ["S608"] +"src/sapling/database.py" = ["PT028"] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = ["tryke.Depends"] diff --git a/src/sapling/backends/memory.py b/src/sapling/backends/memory.py index ff35b11..754495c 100644 --- a/src/sapling/backends/memory.py +++ b/src/sapling/backends/memory.py @@ -3,6 +3,8 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Self +from tryke_guard import __TRYKE_TESTING__ + from sapling.backends.base import Backend from sapling.document import Document from sapling.errors import NotFoundError @@ -103,3 +105,34 @@ def put_many[T: BaseModel]( self, model_class: type[T], models: list[tuple[str, T]] ) -> list[Document[T]]: return [self.put(model_class, model_id, model) for model_id, model in models] + + +if __TRYKE_TESTING__: + from pydantic import BaseModel as _BaseModel + from tryke import describe, expect, test + + with describe("memory backend"): + + class _TestModel(_BaseModel): + hello: str = "world" + + @test + def test_memory_backend() -> None: + backend = MemoryBackend() + backend.initialize() + with backend.transaction() as txn: + txn.put(_TestModel, "test", _TestModel(hello="world")) + doc = txn.fetch(_TestModel, "test") + expect(doc.model.hello).to_equal("world") + + txn.put(_TestModel, "1", _TestModel(hello="one")) + txn.put(_TestModel, "2", _TestModel(hello="two")) + + all_docs = txn.all(_TestModel) + expect(all_docs).to_have_length(3) + expect({d.model_id for d in all_docs}).to_equal({"test", "1", "2"}) + + txn.delete(_TestModel, "test") + expect(txn.get(_TestModel, "test")).to_be_none() + + expect(lambda: txn.fetch(_TestModel, "test")).to_raise(NotFoundError) diff --git a/src/sapling/backends/sqlite.py b/src/sapling/backends/sqlite.py index a9d4d10..4b3a164 100644 --- a/src/sapling/backends/sqlite.py +++ b/src/sapling/backends/sqlite.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Self, override from pydantic import BaseModel +from tryke_guard import __TRYKE_TESTING__ from sapling.backends.base import Backend from sapling.document import Document @@ -280,3 +281,52 @@ def put_many[T: BaseModel]( return [ self._row_to_document(model_class, cursor, row) for row in cursor.fetchall() ] + + +if __TRYKE_TESTING__: + import tempfile + from pathlib import Path + + from tryke import describe, expect, test + + with describe("sqlite backend"): + + class _TestModel(BaseModel): + hello: str = "world" + + @test + def test_sqlite_backend_memory() -> None: + backend = SQLiteBackend() + backend.initialize() + with backend.transaction() as txn: + txn.put(_TestModel, "test", _TestModel(hello="world")) + doc = txn.fetch(_TestModel, "test") + expect(doc.model.hello).to_equal("world") + + @test + def test_sqlite_backend_file() -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + db_path = Path(tmp_dir) / "test.db" + settings = SaplingSettings(sqlite_path=str(db_path)) + backend = SQLiteBackend(settings=settings) + backend.initialize() + with backend.transaction() as txn: + txn.put(_TestModel, "persistent", _TestModel(hello="saved")) + + settings2 = SaplingSettings(sqlite_path=str(db_path)) + backend2 = SQLiteBackend(settings=settings2) + backend2.initialize() + with backend2.transaction() as txn: + doc = txn.fetch(_TestModel, "persistent") + expect(doc.model.hello).to_equal("saved") + + @test + def test_backend_all_method() -> None: + backend = SQLiteBackend() + backend.initialize() + with backend.transaction() as txn: + txn.put(_TestModel, "a", _TestModel(hello="alpha")) + txn.put(_TestModel, "b", _TestModel(hello="beta")) + all_docs = txn.all(_TestModel) + expect(all_docs).to_have_length(2) + expect({d.model_id for d in all_docs}).to_equal({"a", "b"}) diff --git a/src/sapling/database.py b/src/sapling/database.py index 15cbdfb..ec864c0 100644 --- a/src/sapling/database.py +++ b/src/sapling/database.py @@ -1,18 +1,13 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING +from collections.abc import Generator, Iterator +from contextlib import AbstractContextManager +from types import TracebackType from pydantic import BaseModel +from tryke_guard import __TRYKE_TESTING__ -from sapling import SQLiteBackend - -if TYPE_CHECKING: - from collections.abc import Generator, Iterator - from contextlib import AbstractContextManager - from types import TracebackType - - from sapling.backends.base import Backend - from sapling.document import Document +from sapling.backends.base import Backend +from sapling.backends.sqlite import SQLiteBackend +from sapling.document import Document class _TransactionWrapper: @@ -242,3 +237,162 @@ def put_many[T: BaseModel]( """ with self.transaction() as txn: return txn.put_many(model_class, models) + + +if __TRYKE_TESTING__: + from contextlib import contextmanager + from typing import Annotated + + from fastapi import Depends as FastAPIDepends + from fastapi import FastAPI, status + from fastapi.testclient import TestClient + from tryke import Depends, describe, expect, fixture, test + + from sapling.errors import NotFoundError + + with describe("database"): + + class _TestModel(BaseModel): + hello: str = "world" + + @fixture + def database() -> Database: + return Database() + + @fixture + def transaction(db: Database = Depends(database)) -> Generator[Backend]: + with db.transaction() as txn: + yield txn + + @test + def test_basic(txn: Backend = Depends(transaction)) -> None: + hello = _TestModel() + pk = "hello" + record = txn.put(_TestModel, pk, hello) + expect(record.model_id).to_equal(pk) + maybe_record = txn.get(_TestModel, pk) + expect(maybe_record).to_be_truthy() + _record = txn.fetch(_TestModel, pk) + txn.delete(_TestModel, pk) + expect(lambda: txn.fetch(_TestModel, pk)).to_raise(NotFoundError) + + @test + def test_all_method(txn: Backend = Depends(transaction)) -> None: + txn.put(_TestModel, "1", _TestModel(hello="one")) + txn.put(_TestModel, "2", _TestModel(hello="two")) + txn.put(_TestModel, "3", _TestModel(hello="three")) + all_hellos = txn.all(_TestModel) + expect(all_hellos).to_have_length(3) + expect({h.model_id for h in all_hellos}).to_equal({"1", "2", "3"}) + expect({h.model.hello for h in all_hellos}).to_equal( + {"one", "two", "three"} + ) + + @test + def test_all_empty(txn: Backend = Depends(transaction)) -> None: + all_hellos = txn.all(_TestModel) + expect(all_hellos).to_equal([]) + + with describe("initialization"): + + @test + def test_deferred_initialization() -> None: + db = Database(backend=SQLiteBackend(), initialize=False) + db.initialize() + with db.transaction() as txn: + txn.put(_TestModel, "test", _TestModel(hello="world")) + doc = txn.fetch(_TestModel, "test") + expect(doc.model.hello).to_equal("world") + + @test + def test_idempotent_initialization() -> None: + db = Database(backend=SQLiteBackend(), initialize=False) + db.initialize() + db.initialize() + db.initialize() + with db.transaction() as txn: + txn.put(_TestModel, "test", _TestModel(hello="world")) + + @test + def test_uninitialized_error() -> None: + db = Database(backend=SQLiteBackend(), initialize=False) + + def try_uninitialized() -> None: + with db.transaction() as txn: + txn.put(_TestModel, "test", _TestModel(hello="world")) + + expect(try_uninitialized).to_raise(ValueError, match="not initialized") + + with describe("fastapi"): + + class User(BaseModel): + name: str + email: str + + @contextmanager + def _client() -> Generator[TestClient]: + app = FastAPI(debug=True) + db = Database() + + @app.post("/users/{user_id}") + def create_user( + user_id: str, + user: User, + txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], + ) -> dict: + doc = txn.put(User, user_id, user) + return {"id": doc.model_id, "user": doc.model.model_dump()} + + @app.get("/users/{user_id}") + def get_user( + user_id: str, + txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], + ) -> dict: + doc = txn.fetch(User, user_id) + return doc.model.model_dump() + + @app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) + def delete_user( + user_id: str, + txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], + ) -> None: + txn.delete(User, user_id) + + @app.get("/users/{user_id}/error") + def get_user_with_error( + user_id: str, + txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], + ) -> dict: + txn.put(User, user_id, User(name="test", email="test@example.com")) + raise ValueError + + with TestClient(app, raise_server_exceptions=False) as client: + yield client + + @test + def test_create_user() -> None: + with _client() as client: + response = client.post( + "/users/user1", + json={"name": "alice", "email": "alice@example.com"}, + ) + expect(response.status_code).to_equal(status.HTTP_200_OK) + data = response.json() + expect(data["id"]).to_equal("user1") + expect(data["user"]["name"]).to_equal("alice") + + @test + def test_get_nonexistent_user_raises_not_found() -> None: + with _client() as client: + response = client.get("/users/nonexistent") + expect(response.status_code).to_equal( + status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @test + def test_error_in_route_returns_500() -> None: + with _client() as client: + response = client.get("/users/user3/error") + expect(response.status_code).to_equal( + status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_sapling.py b/tests/test_sapling.py deleted file mode 100644 index 9d84196..0000000 --- a/tests/test_sapling.py +++ /dev/null @@ -1,231 +0,0 @@ -import tempfile -from collections.abc import Generator -from contextlib import contextmanager -from pathlib import Path -from typing import Annotated - -from fastapi import Depends as FastAPIDepends -from fastapi import FastAPI, status -from fastapi.testclient import TestClient -from pydantic import BaseModel -from tryke import Depends, describe, expect, fixture, test - -from sapling import Database, MemoryBackend, SQLiteBackend -from sapling.backends.base import Backend -from sapling.errors import NotFoundError -from sapling.settings import SaplingSettings - -with describe("database"): - - class _TestModel(BaseModel): - hello: str = "world" - - @fixture - def database() -> Database: - return Database() - - @fixture - def transaction(db: Database = Depends(database)) -> Generator[Backend]: - with db.transaction() as txn: - yield txn - - @test - def test_basic(txn: Backend = Depends(transaction)) -> None: - hello = _TestModel() - pk = "hello" - record = txn.put(_TestModel, pk, hello) - expect(record.model_id).to_equal(pk) - maybe_record = txn.get(_TestModel, pk) - expect(maybe_record).to_be_truthy() - _record = txn.fetch(_TestModel, pk) - txn.delete(_TestModel, pk) - expect(lambda: txn.fetch(_TestModel, pk)).to_raise(NotFoundError) - - @test - def test_all_method(txn: Backend = Depends(transaction)) -> None: - txn.put(_TestModel, "1", _TestModel(hello="one")) - txn.put(_TestModel, "2", _TestModel(hello="two")) - txn.put(_TestModel, "3", _TestModel(hello="three")) - - all_hellos = txn.all(_TestModel) - expect(all_hellos).to_have_length(3) - expect({h.model_id for h in all_hellos}).to_equal({"1", "2", "3"}) - expect({h.model.hello for h in all_hellos}).to_equal({"one", "two", "three"}) - - @test - def test_all_empty(txn: Backend = Depends(transaction)) -> None: - all_hellos = txn.all(_TestModel) - expect(all_hellos).to_equal([]) - - @test - def test_backend_all_method() -> None: - backend = SQLiteBackend() - db = Database(backend=backend) - - with db.transaction() as txn: - txn.put(_TestModel, "a", _TestModel(hello="alpha")) - txn.put(_TestModel, "b", _TestModel(hello="beta")) - - all_docs = txn.all(_TestModel) - expect(all_docs).to_have_length(2) - expect({d.model_id for d in all_docs}).to_equal({"a", "b"}) - - @test - def test_memory_backend() -> None: - backend = MemoryBackend() - db = Database(backend=backend) - - with db.transaction() as txn: - txn.put(_TestModel, "test", _TestModel(hello="world")) - doc = txn.fetch(_TestModel, "test") - expect(doc.model.hello).to_equal("world") - - txn.put(_TestModel, "1", _TestModel(hello="one")) - txn.put(_TestModel, "2", _TestModel(hello="two")) - - all_docs = txn.all(_TestModel) - expect(all_docs).to_have_length(3) - expect({d.model_id for d in all_docs}).to_equal({"test", "1", "2"}) - - txn.delete(_TestModel, "test") - expect(txn.get(_TestModel, "test")).to_be_none() - - expect(lambda: txn.fetch(_TestModel, "test")).to_raise(NotFoundError) - - -with describe("sqlite"): - - @test - def test_sqlite_backend_memory() -> None: - backend = SQLiteBackend() - db = Database(backend=backend) - with db.transaction() as txn: - txn.put(_TestModel, "test", _TestModel(hello="world")) - doc = txn.fetch(_TestModel, "test") - expect(doc.model.hello).to_equal("world") - - @test - def test_sqlite_backend_file() -> None: - with tempfile.TemporaryDirectory() as tmp_dir: - db_path = Path(tmp_dir) / "test.db" - settings = SaplingSettings(sqlite_path=str(db_path)) - backend = SQLiteBackend(settings=settings) - db = Database(backend=backend) - - with db.transaction() as txn: - txn.put(_TestModel, "persistent", _TestModel(hello="saved")) - - settings2 = SaplingSettings(sqlite_path=str(db_path)) - db2 = Database(backend=SQLiteBackend(settings=settings2)) - with db2.transaction() as txn: - doc = txn.fetch(_TestModel, "persistent") - expect(doc.model.hello).to_equal("saved") - - -with describe("initialization"): - - @test - def test_deferred_initialization() -> None: - backend = SQLiteBackend() - db = Database(backend=backend, initialize=False) - - db.initialize() - - with db.transaction() as txn: - txn.put(_TestModel, "test", _TestModel(hello="world")) - doc = txn.fetch(_TestModel, "test") - expect(doc.model.hello).to_equal("world") - - @test - def test_idempotent_initialization() -> None: - backend = SQLiteBackend() - db = Database(backend=backend, initialize=False) - - db.initialize() - db.initialize() - db.initialize() - - with db.transaction() as txn: - txn.put(_TestModel, "test", _TestModel(hello="world")) - - @test - def test_uninitialized_error() -> None: - backend = SQLiteBackend() - db = Database(backend=backend, initialize=False) - - def try_uninitialized() -> None: - with db.transaction() as txn: - txn.put(_TestModel, "test", _TestModel(hello="world")) - - expect(try_uninitialized).to_raise(ValueError, match="not initialized") - - -with describe("fastapi"): - - class User(BaseModel): - name: str - email: str - - @contextmanager - def _client() -> Generator[TestClient]: - app = FastAPI(debug=True) - db = Database() - - @app.post("/users/{user_id}") - def create_user( - user_id: str, - user: User, - txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], - ) -> dict: - doc = txn.put(User, user_id, user) - return {"id": doc.model_id, "user": doc.model.model_dump()} - - @app.get("/users/{user_id}") - def get_user( - user_id: str, - txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], - ) -> dict: - doc = txn.fetch(User, user_id) - return doc.model.model_dump() - - @app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) - def delete_user( - user_id: str, - txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], - ) -> None: - txn.delete(User, user_id) - - @app.get("/users/{user_id}/error") - def get_user_with_error( - user_id: str, - txn: Annotated[Backend, FastAPIDepends(db.transaction_dependency)], - ) -> dict: - txn.put(User, user_id, User(name="test", email="test@example.com")) - raise ValueError - - with TestClient(app, raise_server_exceptions=False) as client: - yield client - - @test - def test_create_user(): - with _client() as client: - response = client.post( - "/users/user1", - json={"name": "alice", "email": "alice@example.com"}, - ) - expect(response.status_code).to_equal(status.HTTP_200_OK) - data = response.json() - expect(data["id"]).to_equal("user1") - expect(data["user"]["name"]).to_equal("alice") - - @test - def test_get_nonexistent_user_raises_not_found(): - with _client() as client: - response = client.get("/users/nonexistent") - expect(response.status_code).to_equal(status.HTTP_500_INTERNAL_SERVER_ERROR) - - @test - def test_error_in_route_returns_500(): - with _client() as client: - response = client.get("/users/user3/error") - expect(response.status_code).to_equal(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/uv.lock b/uv.lock index f60438e..9543070 100644 --- a/uv.lock +++ b/uv.lock @@ -403,8 +403,8 @@ wheels = [ [[package]] name = "tryke" -version = "0.0.15" -source = { git = "https://github.com/thejchap/tryke#d3827c9c25d884427b80ee7ba215e7498ac1bffd" } +version = "0.0.18" +source = { git = "https://github.com/thejchap/tryke#6be2d240c958d5701818f67763e31dd6d1cab15a" } [[package]] name = "ty"