From 2e1d35d566a2fa16be04d5c55441818c727ad5ac Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 16 Sep 2026 15:52:06 +0200 Subject: [PATCH] RavenDB-27141 Seed test databases from a .ravendbdump The client shipped a smuggler in 7.2.6, so DatabaseDumpFilePath and DatabaseDumpFileStream can finally be ported. The stream hook is asked once per driver and rewound between databases, which C# does not do. --- README.md | 24 +++++ ravendb_test_driver/raven_test_driver.py | 37 +++++++- setup.py | 4 +- tests/test_dump_seeding.py | 111 +++++++++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 tests/test_dump_seeding.py diff --git a/README.md b/README.md index fbc53c1..913d6d5 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,30 @@ class PeopleTestDriver(RavenTestDriver): database_record.settings["Indexing.MapTimeoutInSec"] = "30" ``` +Override `database_dump_file_path(self)` to seed every database from a `.ravendbdump` instead of +writing the documents by hand. The import runs before `setup_database`: + +```python +class PeopleTestDriver(RavenTestDriver): + def database_dump_file_path(self): + return "fixtures/people.ravendbdump" +``` + +`database_dump_file_stream(self)` takes an open binary stream instead of a path. The driver asks +for it once, rewinds it between databases, and closes it when the driver closes: + +```python +class PeopleTestDriver(RavenTestDriver): + def database_dump_file_stream(self): + return importlib.resources.files("tests.fixtures").joinpath("people.ravendbdump").open("rb") +``` + +Produce a dump from a database you already have with the client's smuggler: + +```python +store.smuggler.for_database(store.database).export(DatabaseSmugglerExportOptions(), "people.ravendbdump") +``` + Use `GetDocumentStoreOptions.wait_for_indexing_timeout` when a store should not be returned until indexing settles, or call `wait_for_indexing(store)` directly. It waits until every applicable index is non-stale and any side-by-side replacement has been swapped in. diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index f12bcb2..8ff3547 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -10,10 +10,11 @@ import warnings import webbrowser from datetime import timedelta -from typing import Optional, Dict, Any, Callable +from typing import IO, Optional, Dict, Any, Callable from urllib.parse import quote from ravendb import ( + DatabaseSmugglerImportOptions, DocumentStore, Lazy, CreateDatabaseOperation, @@ -57,6 +58,7 @@ def __init__(self) -> None: self.disposed = False self._document_stores: Dict[DocumentStore, bool] = {} self.on_driver_closed: Optional[Callable[[RavenTestDriver], None]] = None + self._dump_stream: Optional[IO[bytes]] = None def __enter__(self) -> "RavenTestDriver": return self @@ -147,6 +149,7 @@ def __close_event_callback(): store.add_after_close(__close_event_callback) + self._import_database_dump(store, database_record.database_name) self.setup_database(store) if options.wait_for_indexing_timeout is not None: @@ -156,6 +159,32 @@ def __close_event_callback(): return store + def database_dump_file_path(self) -> Optional[str]: + """Override to seed every database the driver creates from a .ravendbdump file.""" + return None + + def database_dump_file_stream(self) -> Optional[IO[bytes]]: + """Override to seed from an open binary stream. The driver closes it on close().""" + return None + + def _import_database_dump(self, store: DocumentStore, database_name: str) -> None: + source = self.database_dump_file_path() or self._owned_dump_stream() + if source is None: + return + + operation = store.smuggler.for_database(database_name).import_data(DatabaseSmugglerImportOptions(), source) + operation.wait_for_completion() + + def _owned_dump_stream(self) -> Optional[IO[bytes]]: + if self._dump_stream is None: + # Asked once: re-reading the hook per database would leak a stream per store. + self._dump_stream = self.database_dump_file_stream() + + if self._dump_stream is not None and self._dump_stream.seekable(): + self._dump_stream.seek(0) + + return self._dump_stream + @staticmethod def _delete_test_database(store: DocumentStore, database_name: str) -> None: """Hard-delete a test database, ignoring the failures that are not the test's problem.""" @@ -332,6 +361,12 @@ def close(self) -> None: except Exception as e: exceptions.append(e) + if self._dump_stream is not None: + try: + self._dump_stream.close() + except Exception as e: + exceptions.append(e) + if self.on_driver_closed: # Collected, so a raising callback cannot discard the store-close errors. try: diff --git a/setup.py b/setup.py index 11f0116..8d9344f 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb-test-driver", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.2.5.post4", + version="7.2.6", description="RavenDB package for writing integration tests against RavenDB server", long_description_content_type="text/markdown", long_description=open("README.md").read(), @@ -14,5 +14,5 @@ keywords=["ravendb", "nosql", "database", "test", "driver"], python_requires=">=3.10", license_files=["LICENSE"], - install_requires=["ravendb-embedded~=7.2.5", "ravendb~=7.2.3"], + install_requires=["ravendb-embedded~=7.2.5", "ravendb~=7.2.6"], ) diff --git a/tests/test_dump_seeding.py b/tests/test_dump_seeding.py new file mode 100644 index 0000000..8b93c3f --- /dev/null +++ b/tests/test_dump_seeding.py @@ -0,0 +1,111 @@ +"""Seeding every test database from a .ravendbdump, the way the .NET driver does.""" + +import io +import tempfile +from pathlib import Path +from unittest import TestCase, skipIf + +from ravendb import DatabaseSmugglerExportOptions + +from ravendb_test_driver import RavenTestDriver +from tests.support import attach_mode_is_active + +skip_without_own_server = skipIf(attach_mode_is_active(), "attach mode: the driver does not own the server") + + +class Person: + def __init__(self, Id=None, name=None): + self.Id = Id + self.name = name + + +def _dump_of_two_people(directory: str) -> str: + """Build a real dump by seeding a database and exporting it.""" + dump = str(Path(directory, "people.ravendbdump")) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + session.store(Person(name="Ayende"), "people/1") + session.store(Person(name="Oren"), "people/2") + session.save_changes() + + store.smuggler.for_database(store.database).export(DatabaseSmugglerExportOptions(), dump) + + return dump + + +class _FilePathDriver(RavenTestDriver): + dump_path = None + + def database_dump_file_path(self): + return self.dump_path + + +class _StreamDriver(RavenTestDriver): + def __init__(self, dump_path): + super().__init__() + self._path = dump_path + self.opened = 0 + + def database_dump_file_stream(self): + self.opened += 1 + return open(self._path, "rb") + + +@skip_without_own_server +class TestDumpSeeding(TestCase): + def test_a_dump_path_seeds_every_database(self): + with tempfile.TemporaryDirectory() as directory: + _FilePathDriver.dump_path = _dump_of_two_people(directory) + self.addCleanup(setattr, _FilePathDriver, "dump_path", None) + + with _FilePathDriver() as driver: + for _ in range(2): + with driver.get_document_store() as store: + with store.open_session() as session: + self.assertEqual("Ayende", session.load("people/1", Person).name) + self.assertEqual("Oren", session.load("people/2", Person).name) + + def test_a_dump_stream_is_read_once_and_reused(self): + with tempfile.TemporaryDirectory() as directory: + dump = _dump_of_two_people(directory) + + driver = _StreamDriver(dump) + with driver.get_document_store() as first, driver.get_document_store() as second: + for store in (first, second): + with store.open_session() as session: + self.assertEqual("Ayende", session.load("people/1", Person).name) + + # The hook is asked once; the stream is rewound for the second database. + self.assertEqual(1, driver.opened) + + driver.close() + self.assertTrue(driver._dump_stream.closed) + + def test_no_dump_means_an_empty_database(self): + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + self.assertIsNone(session.load("people/1", Person)) + + +class TestDumpHooksAreEmptyByDefault(TestCase): + def test_both_hooks_return_none(self): + driver = RavenTestDriver() + + self.assertIsNone(driver.database_dump_file_path()) + self.assertIsNone(driver.database_dump_file_stream()) + + def test_closing_without_a_dump_stream_is_fine(self): + driver = RavenTestDriver() + driver.close() + + self.assertIsNone(driver._dump_stream) + + def test_a_stream_the_driver_never_used_is_left_alone(self): + driver = RavenTestDriver() + driver._dump_stream = io.BytesIO(b"") + driver.close() + + self.assertTrue(driver._dump_stream.closed)