From c72db70c04c7a92815dfa5669be6b3cf5196420d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 12:18:03 +0200 Subject: [PATCH 01/32] RavenDB-27141 Add TestServerOptions and DriverCloseError --- ravendb_test_driver/__init__.py | 10 +++++++++- ravendb_test_driver/errors.py | 17 +++++++++++++++++ ravendb_test_driver/options.py | 13 +++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 ravendb_test_driver/errors.py diff --git a/ravendb_test_driver/__init__.py b/ravendb_test_driver/__init__.py index e06a8ea..b263a7d 100644 --- a/ravendb_test_driver/__init__.py +++ b/ravendb_test_driver/__init__.py @@ -1,2 +1,10 @@ +from ravendb_test_driver.errors import DriverCloseError from ravendb_test_driver.raven_test_driver import RavenTestDriver -from ravendb_test_driver.options import GetDocumentStoreOptions +from ravendb_test_driver.options import GetDocumentStoreOptions, TestServerOptions + +__all__ = [ + "DriverCloseError", + "GetDocumentStoreOptions", + "RavenTestDriver", + "TestServerOptions", +] diff --git a/ravendb_test_driver/errors.py b/ravendb_test_driver/errors.py new file mode 100644 index 0000000..d6f583c --- /dev/null +++ b/ravendb_test_driver/errors.py @@ -0,0 +1,17 @@ +from typing import Iterable, List + + +class DriverCloseError(RuntimeError): + """Raised when closing the driver hit one or more errors. + + Subclasses RuntimeError so existing `except RuntimeError` handlers keep working, and keeps + the original exceptions in `exceptions` instead of flattening them into a string, which is + what AggregateException.InnerExceptions gives the C# driver. + """ + + def __init__(self, exceptions: Iterable[BaseException]) -> None: + self.exceptions: List[BaseException] = list(exceptions) + super().__init__( + f"{len(self.exceptions)} error(s) while closing the test driver: " + + "; ".join(f"{type(e).__name__}: {e}" for e in self.exceptions) + ) diff --git a/ravendb_test_driver/options.py b/ravendb_test_driver/options.py index 9795d98..313ec70 100644 --- a/ravendb_test_driver/options.py +++ b/ravendb_test_driver/options.py @@ -3,6 +3,19 @@ from datetime import timedelta from typing import Optional +from ravendb_embedded import ServerOptions + + +class TestServerOptions(ServerOptions): + """Embedded server options for a test run. + + The defaults a test server needs (in-memory storage, a scratch data directory, an empty + settings file) are applied by the driver to *any* `ServerOptions` it is given, right before + the server starts, so `configure_server` keeps accepting the base type and nobody has to + migrate. This subclass is the documented entry point for that intent, and the place where + test-only defaults that cannot be inferred from a plain `ServerOptions` will live. + """ + class GetDocumentStoreOptions: def __init__(self): From cd8f6c93678b81962a687e846b23e00b67c410e0 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 12:18:03 +0200 Subject: [PATCH 02/32] RavenDB-27141 Fix driver lifecycle and give test servers their own defaults --- ravendb_test_driver/raven_test_driver.py | 310 +++++++++++++++++++---- tests/test_driver_lifecycle.py | 177 +++++++++++++ tests/test_server_options.py | 246 ++++++++++++++++++ 3 files changed, 677 insertions(+), 56 deletions(-) create mode 100644 tests/test_driver_lifecycle.py create mode 100644 tests/test_server_options.py diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index e4c87d1..e3ec301 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -1,8 +1,12 @@ import atexit +import logging import os import shutil +import sys import tempfile +import threading import time +import warnings import webbrowser from datetime import timedelta from typing import Optional, Dict, Any, Callable @@ -16,6 +20,7 @@ GetIndexErrorsOperation, ) from ravendb.documents.indexes.definitions import IndexState +from ravendb.exceptions.cluster import NoLoaderException from ravendb.exceptions.exceptions import ( DatabaseDoesNotExistException, TimeoutException, @@ -26,13 +31,23 @@ from ravendb.serverwide.operations.common import DeleteDatabaseOperation from ravendb_embedded import EmbeddedServer, ServerOptions -from ravendb_test_driver.options import GetDocumentStoreOptions +from ravendb_test_driver.errors import DriverCloseError +from ravendb_test_driver.options import GetDocumentStoreOptions, TestServerOptions + +_LOGGER = logging.getLogger(__name__) + +_WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_DRIVER_WAIT_FOR_USER" +_FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) class RavenTestDriver: + # Test servers run in memory unless a subclass or a caller's command line says otherwise. + run_in_memory: bool = True + _TEST_SERVER: EmbeddedServer = EmbeddedServer() _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver.run_server()) _INDEX = 0 + _INDEX_LOCK = threading.Lock() _GLOBAL_SERVER_OPTIONS: Optional[ServerOptions] = None _EMPTY_SETTINGS_FILE_NAME: Optional[str] = None _EXTERNAL_SERVER_URL: Optional[str] = None @@ -50,6 +65,21 @@ def __enter__(self) -> "RavenTestDriver": def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() + @staticmethod + def _next_index() -> int: + # Qualified with RavenTestDriver on purpose: 'cls._INDEX += 1' would shadow the class + # attribute with a subclass (or instance) one and every driver would restart at 1. + with RavenTestDriver._INDEX_LOCK: + RavenTestDriver._INDEX += 1 + return RavenTestDriver._INDEX + + @staticmethod + def _remove_empty_settings_file(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass # already gone, or held by something else + @staticmethod def _get_empty_settings_file() -> str: if not RavenTestDriver._EMPTY_SETTINGS_FILE_NAME: @@ -57,14 +87,16 @@ def _get_empty_settings_file() -> str: temp_file.write(b"{}") temp_file.close() RavenTestDriver._EMPTY_SETTINGS_FILE_NAME = temp_file.name + # Registered before the embedded server's own atexit hook, so it runs after it (LIFO). + atexit.register(RavenTestDriver._remove_empty_settings_file, temp_file.name) return RavenTestDriver._EMPTY_SETTINGS_FILE_NAME @staticmethod def configure_server(options: ServerOptions) -> None: if RavenTestDriver._TEST_SERVER_STORE.is_value_created: raise RuntimeError( - "Cannot configure server after it was started. " - "Please call 'configureServer' method before any 'getDocumentStore' is called." + "Cannot configure the server after it was started. " + "Call 'configure_server' before any 'get_document_store'." ) RavenTestDriver._GLOBAL_SERVER_OPTIONS = options @@ -96,11 +128,13 @@ def get_document_store( ) -> DocumentStore: database = database or "test" options = options or GetDocumentStoreOptions() - self._INDEX += 1 - name = f"{database}_{self._INDEX}" + name = f"{database}_{RavenTestDriver._next_index()}" document_store = self._TEST_SERVER_STORE.value - create_database_operation = CreateDatabaseOperation(DatabaseRecord(name)) + database_record = DatabaseRecord(name) + self.pre_configure_database(database_record) + + create_database_operation = CreateDatabaseOperation(database_record) document_store.maintenance.server.send(create_database_operation) store = DocumentStore(document_store.urls, name) @@ -119,15 +153,24 @@ def __close_event_callback(): return try: - store.maintenance.server.send(DeleteDatabaseOperation(store.database, True)) - except DatabaseDoesNotExistException: + # database_record.database_name, not store.database: a subclass may have renamed + # the record in pre_configure_database, and the database it created is the one to + # delete. + store.maintenance.server.send(DeleteDatabaseOperation(database_record.database_name, True)) + except (DatabaseDoesNotExistException, NoLoaderException): pass # ignore + except RavenException as e: + # The client registers the server's NoLeaderException under a misspelled key + # ('NoLoaderException'), so a real no-leader failure arrives as a plain + # RavenException. Drop this branch once the client mapping is fixed. + if "NoLeaderException" not in str(e): + raise store.add_after_close(__close_event_callback) self.setup_database(store) - if options.wait_for_indexing_timeout: + if options.wait_for_indexing_timeout is not None: self.wait_for_indexing(store, name, options.wait_for_indexing_timeout) self._document_stores[store] = True @@ -137,6 +180,9 @@ def __close_event_callback(): def pre_initialize(self, document_store: DocumentStore) -> None: pass # empty by design + def pre_configure_database(self, database_record: DatabaseRecord) -> None: + pass # empty by design + def setup_database(self, document_store: DocumentStore) -> None: pass # empty by design @@ -146,23 +192,24 @@ def wait_for_indexing( database: Optional[str] = None, timeout: Optional[timedelta] = None, ) -> None: - database = database or None - timeout = timeout or timedelta(seconds=60) # Default timeout + timeout = timeout if timeout is not None else timedelta(seconds=60) # Default timeout admin = store.maintenance.for_database(database) start_time = time.monotonic() while time.monotonic() - start_time < timeout.total_seconds(): database_statistics = admin.send(GetStatisticsOperation()) - stale = [ + # Return only once every applicable index is non-stale AND no side-by-side + # replacement is left, so a pending index swap keeps us waiting instead of + # handing the caller results from the pre-swap index. + pending = [ x for x in database_statistics.indexes if x.state != IndexState.DISABLED - and x.stale - and not x.name.startswith(Documents.Indexing.SIDE_BY_SIDE_INDEX_NAME_PREFIX) + and (x.stale or x.name.startswith(Documents.Indexing.SIDE_BY_SIDE_INDEX_NAME_PREFIX)) ] - if not stale: + if not pending: return if any(index.state == IndexState.ERROR for index in database_statistics.indexes): @@ -181,9 +228,51 @@ def wait_for_indexing( else "" ) - raise TimeoutException(f"The indexes stayed stale for more than {timeout} seconds. {all_index_errors_text}") + raise TimeoutException(f"The indexes stayed stale for more than {timeout}. {all_index_errors_text}") + + @staticmethod + def _is_debugger_attached() -> bool: + """Used only to make the wait unbounded, never to skip it. + + sys.gettrace() is deliberately not consulted: coverage.py installs a trace function, so + every pytest-cov run would claim a debugger is attached. A false negative here costs the + default timeout, not a silently skipped inspection point. + """ + debugpy = sys.modules.get("debugpy") + if debugpy is not None: + try: + if debugpy.is_client_connected(): + return True + except Exception: # pragma: no cover - debugpy internals + pass + + pydevd = sys.modules.get("pydevd") + if pydevd is not None: + try: + return pydevd.get_global_debugger() is not None + except Exception: # pragma: no cover - pydevd internals + pass + + return False + + def wait_for_user_to_continue_the_test( + self, + store: DocumentStore, + timeout: Optional[timedelta] = timedelta(minutes=5), + ) -> None: + """Open Studio and block until a 'Debug/Done' document shows up in this database. + + Bounded by `timeout` so a call left in committed code fails a CI job fast instead of + hanging it; pass timeout=None to wait forever. Set RAVENDB_TEST_DRIVER_WAIT_FOR_USER to + 0/false/no/off to skip the wait entirely. + """ + environment_value = os.environ.get(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE) + if environment_value is not None and environment_value.strip().lower() in _FALSY_ENVIRONMENT_VALUES: + return + + if self._is_debugger_attached(): + timeout = None - def wait_for_user_to_continue_the_test(self, store: DocumentStore) -> None: database_name_encoded = quote(store.database, safe="") documents_page = ( f"{store.urls[0]}/studio/index.html#databases/documents?&database={database_name_encoded}&withStop=true" @@ -191,19 +280,36 @@ def wait_for_user_to_continue_the_test(self, store: DocumentStore) -> None: self.open_browser(documents_page) + start_time = time.monotonic() while True: + if timeout is not None and time.monotonic() - start_time >= timeout.total_seconds(): + raise TimeoutException( + f"No 'Debug/Done' document showed up in '{store.database}' within {timeout}. " + "Store a document with that id to continue the test, pass timeout=None to wait " + f"forever, or set {_WAIT_FOR_USER_ENVIRONMENT_VARIABLE}=0 to skip this wait." + ) + time.sleep(0.5) with store.open_session() as session: - if session.load("Debug/Done", dict): + # Existence check instead of load(): no document is tracked, and the marker is + # deleted so a later wait on the same store cannot return on a stale one. + if session.advanced.exists("Debug/Done"): + session.delete("Debug/Done") + session.save_changes() break @staticmethod def open_browser(url: str) -> None: print(url) try: - webbrowser.open(url) + opened = webbrowser.open(url) except Exception as e: - raise RuntimeError() from e + raise RuntimeError(f"Failed to open a browser at {url}") from e + + if not opened: + # Headless machines return False rather than raising. The wait itself still works + # through the Debug/Done marker, so this is a note, not a failure. + print("No browser could be opened here; use the URL above.") def close(self) -> None: if getattr(self, "disposed", False): @@ -211,55 +317,113 @@ def close(self) -> None: exceptions = [] - for document_store in self._document_stores: + try: + # Snapshot: each store's after-close callback pops itself out of _document_stores, + # and mutating the dict we iterate raises RuntimeError from the for statement itself. + for document_store in list(self._document_stores): + try: + document_store.close() + except Exception as e: + exceptions.append(e) + finally: + self.disposed = True + + if self.on_driver_closed: + # Collected, not raised on the spot: a raising callback used to discard every + # store-close error gathered above. try: - document_store.close() + self.on_driver_closed(self) except Exception as e: exceptions.append(e) - self.disposed = True - - if self.on_driver_closed: - self.on_driver_closed(self) - if exceptions: - raise RuntimeError(", ".join(map(str, exceptions))) + raise DriverCloseError(exceptions) @staticmethod def cleanup_temp_dirs(*dirs: str) -> None: - try: - for i in range(30): - any_failure = False - for dir_ in dirs: - if os.path.exists(dir_): - if not shutil.rmtree(dir_, ignore_errors=True): - any_failure = True - if not any_failure: - return - time.sleep(0.2) - except Exception: - pass + for _ in range(30): + any_failure = False + for dir_ in dirs: + if not os.path.exists(dir_): + continue + # rmtree returns None, so its return value says nothing about success; + # the directory still being there is the only real signal. + shutil.rmtree(dir_, ignore_errors=True) + if os.path.exists(dir_): + any_failure = True + if not any_failure: + return + time.sleep(0.2) @staticmethod def default_server_options() -> ServerOptions: - options = ServerOptions() + return RavenTestDriver._normalize_test_server_options(TestServerOptions()) + + @classmethod + def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions: + """Give any ServerOptions the defaults a test server needs. + + This is what C# gets from typing ConfigureServer(TestServerOptions), without breaking + callers who pass a plain ServerOptions. Idempotent, and it never overrides a value the + caller set explicitly. + """ + security = getattr(options, "security", None) + if security is not None and not security.client_pem_certificate_path: + raise RavenException( + "A secured test server needs a client certificate the test client can " + "authenticate with. Pass client_pem_certificate_path to ServerOptions.secured()." + ) - data_dir = tempfile.mkdtemp() - logs_dir = tempfile.mkdtemp() + # A local copy: the caller's list is theirs, and from here on there is more than one writer. + command_line_args = list(options.command_line_args) - options.data_directory = data_dir - options.logs_path = logs_dir + settings_file = cls._get_empty_settings_file() + if settings_file not in command_line_args: + command_line_args[:0] = ["-c", settings_file] - def cleanup_temp_dirs() -> None: - RavenTestDriver.cleanup_temp_dirs(data_dir, logs_dir) + if cls.run_in_memory and not any(arg.startswith("--RunInMemory") for arg in command_line_args): + command_line_args.append("--RunInMemory=true") - atexit.register(cleanup_temp_dirs) + options.command_line_args = command_line_args + + # Untouched embedded default: the data directory sits inside the installed package, so a + # test run would write into its own dependency tree. Logs follow the data directory. + default_data_directory = getattr(ServerOptions, "_DEFAULT_DATA_DIRECTORY", None) + if default_data_directory is not None and options.data_directory == default_data_directory: + data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") + options.data_directory = data_directory + atexit.register(cls.cleanup_temp_dirs, data_directory) + _LOGGER.info("Test server data and logs redirected to %s", data_directory) return options + @classmethod + def _resolve_external_server_url(cls) -> Optional[str]: + """Explicit configuration beats the environment. + + RAVENDB_TEST_SERVER_URL used to win over configure_server(), which silently redirected a + suite pinned to the embedded server onto someone else's - where the driver then creates + and hard-deletes databases. + """ + if cls._EXTERNAL_SERVER_URL: + return cls._EXTERNAL_SERVER_URL + + environment_url = os.environ.get("RAVENDB_TEST_SERVER_URL") + if environment_url and cls._GLOBAL_SERVER_OPTIONS is not None: + warnings.warn( + f"Ignoring RAVENDB_TEST_SERVER_URL={environment_url!r} because configure_server() " + "was called explicitly. Drop that call to attach to the server from the " + "environment; the driver creates and hard-deletes databases on whichever server " + "it ends up using.", + stacklevel=2, + ) + return None + + return environment_url + @classmethod def run_server(cls) -> DocumentStore: - external_url = cls._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL") + external_url = cls._resolve_external_server_url() if external_url: # Attach to an existing server; do not boot the embedded one (no .NET needed). certificate = cls._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") @@ -278,14 +442,12 @@ def run_server(cls) -> DocumentStore: return store try: - options = RavenTestDriver._GLOBAL_SERVER_OPTIONS or RavenTestDriver.default_server_options() - - command_line_args = options.command_line_args - - command_line_args.insert(0, "-c") - command_line_args.insert(1, RavenTestDriver._get_empty_settings_file()) + options = cls._GLOBAL_SERVER_OPTIONS or TestServerOptions() + cls._normalize_test_server_options(options) except Exception as e: - raise RavenException(f"Unable to start server: {e}") + # Only the option preparation above is wrapped; start_server below raises the + # embedded layer's own, richer error. + raise RavenException(f"Unable to prepare the test server options: {e}", e) from e cls._TEST_SERVER.start_server(options) @@ -293,6 +455,42 @@ def run_server(cls) -> DocumentStore: store = DocumentStore(url, None) + # A secured embedded server hands its client material to EmbeddedServer.start_server; + # without copying it here the test client cannot authenticate to the server we just booted. + if cls._TEST_SERVER.client_pem_certificate_path: + store.certificate_pem_path = cls._TEST_SERVER.client_pem_certificate_path + if cls._TEST_SERVER.trust_store_path: + store.trust_store_path = cls._TEST_SERVER.trust_store_path + store.initialize() return store + + @classmethod + def stop_test_server(cls) -> None: + """Close the shared test server and its server-level store. + + Nothing else closes them: driver.close() only owns the per-test stores, so without this + the server lives until interpreter exit and its shutdown cost lands after the test runner + has printed its summary. Idempotent, and the server can be started again afterwards. + """ + lazy = cls._TEST_SERVER_STORE + if lazy.is_value_created: + try: + lazy.value.close() + finally: + cls._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver.run_server()) + + cls._TEST_SERVER.close() + + @classmethod + def reset_server_configuration(cls) -> None: + """Forget configure_server / configure_external_server, without touching the server. + + Kept separate from stop_test_server on purpose: someone freeing resources should not + silently lose the configuration they registered. + """ + cls._GLOBAL_SERVER_OPTIONS = None + cls._EXTERNAL_SERVER_URL = None + cls._EXTERNAL_SERVER_CERT = None + cls._EXTERNAL_SERVER_TRUST_STORE = None diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py new file mode 100644 index 0000000..269aeab --- /dev/null +++ b/tests/test_driver_lifecycle.py @@ -0,0 +1,177 @@ +"""Regressions around driver lifecycle, database-name allocation, indexing waits and cleanup. + +None of these paths had coverage before, which is why the defects survived: every other test +and lab closes its store in a nested `with` block and never closes the driver itself. +""" + +import os +import tempfile +import time +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase + +from ravendb import GetIndexErrorsOperation, GetDatabaseRecordOperation +from ravendb.documents.indexes.definitions import IndexState +from ravendb.documents.operations.statistics import IndexInformation +from ravendb.exceptions.exceptions import TimeoutException + +from ravendb_test_driver import RavenTestDriver + + +class TestDriverClose(TestCase): + def test_closing_the_driver_closes_a_store_left_open(self): + # close() used to iterate _document_stores while each store's after-close callback + # popped itself out of that same dict, so the for statement raised + # RuntimeError: dictionary changed size during iteration - outside the try/except, + # leaving disposed unset and on_driver_closed unfired. + driver = RavenTestDriver() + store = driver.get_document_store() + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() + + closed = [] + driver.on_driver_closed = closed.append + + driver.close() + + self.assertTrue(driver.disposed) + self.assertEqual([driver], closed) + self.assertEqual(0, len(driver._document_stores)) + + def test_closing_the_driver_twice_is_a_no_op(self): + driver = RavenTestDriver() + with driver.get_document_store(): + pass + + driver.close() + driver.close() + + self.assertTrue(driver.disposed) + + +class TestDatabaseNameAllocation(TestCase): + def test_two_drivers_get_distinct_database_names(self): + # self._INDEX += 1 read the class attribute and wrote an instance one, so every + # driver restarted numbering at 1 and two live drivers both asked for test_1. + with RavenTestDriver() as first, RavenTestDriver() as second: + with first.get_document_store() as first_store: + with second.get_document_store() as second_store: + self.assertNotEqual(first_store.database, second_store.database) + + def test_index_stays_a_class_attribute(self): + driver = RavenTestDriver() + before = RavenTestDriver._INDEX + with driver.get_document_store(): + pass + + self.assertEqual(before + 1, RavenTestDriver._INDEX) + self.assertNotIn("_INDEX", driver.__dict__) + driver.close() + + +class _PreConfiguringDriver(RavenTestDriver): + def __init__(self): + super().__init__() + self.records = [] + + def pre_configure_database(self, database_record) -> None: + self.records.append(database_record) + database_record.settings["Indexing.MapTimeoutInSec"] = "17" + + +class TestPreConfigureDatabase(TestCase): + def test_hook_can_change_the_record_before_the_database_is_created(self): + driver = _PreConfiguringDriver() + with driver.get_document_store() as store: + self.assertEqual(1, len(driver.records)) + self.assertEqual(store.database, driver.records[0].database_name) + + created = store.maintenance.server.send(GetDatabaseRecordOperation(store.database)) + self.assertEqual("17", created.settings["Indexing.MapTimeoutInSec"]) + + driver.close() + + +def _index(name, stale=False, state=IndexState.NORMAL): + return IndexInformation(stale=stale, index_state=state, name=name) + + +class _FakeAdmin: + """Serves canned GetStatisticsOperation results, one per poll, then repeats the last.""" + + def __init__(self, *statistics): + self._statistics = list(statistics) + self.polls = 0 + + def send(self, operation): + if isinstance(operation, GetIndexErrorsOperation): + return [] + self.polls += 1 + return self._statistics.pop(0) if len(self._statistics) > 1 else self._statistics[0] + + +class _FakeStore: + def __init__(self, admin): + self.maintenance = SimpleNamespace(for_database=lambda database=None: admin) + + +class TestWaitForIndexing(TestCase): + def test_returns_once_nothing_is_stale(self): + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/ByCompany")])) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(1, admin.polls) + + def test_keeps_polling_while_something_is_stale(self): + admin = _FakeAdmin( + SimpleNamespace(indexes=[_index("Orders/ByCompany", stale=True)]), + SimpleNamespace(indexes=[_index("Orders/ByCompany")]), + ) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(2, admin.polls) + + def test_waits_for_a_side_by_side_replacement_to_be_swapped_in(self): + # A replacement index blocks the return even when it is not stale, matching C#: + # until the server swaps it in, querying the original returns pre-swap results. + # The old predicate excluded ReplacementOf/ indexes and returned immediately. + admin = _FakeAdmin( + SimpleNamespace( + indexes=[ + _index("Orders/ByCompany"), + _index("ReplacementOf/Orders/ByCompany"), + ] + ) + ) + + with self.assertRaises(TimeoutException): + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(milliseconds=200)) + + self.assertGreater(admin.polls, 0) + + def test_disabled_indexes_are_ignored(self): + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/Disabled", stale=True, state=IndexState.DISABLED)])) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(1, admin.polls) + + +class TestCleanupTempDirs(TestCase): + def test_cleanup_returns_as_soon_as_the_directory_is_gone(self): + # The retry loop read shutil.rmtree's None return as a failure flag, so a successful + # delete still cost one 0.2s sleep and a second pass. + directory = tempfile.mkdtemp() + Path(directory, "file.txt").write_text("x", encoding="utf-8") + + started = time.monotonic() + RavenTestDriver.cleanup_temp_dirs(directory) + elapsed = time.monotonic() - started + + self.assertFalse(os.path.exists(directory)) + self.assertLess(elapsed, 0.15) diff --git a/tests/test_server_options.py b/tests/test_server_options.py new file mode 100644 index 0000000..03f9ada --- /dev/null +++ b/tests/test_server_options.py @@ -0,0 +1,246 @@ +"""Test-server option normalization, server selection precedence, and shared-server teardown. + +Everything except the last class is hermetic: no server is started, so these run anywhere. +""" + +import os +import warnings +from datetime import timedelta +from types import SimpleNamespace +from unittest import TestCase + +from ravendb.exceptions.exceptions import TimeoutException +from ravendb.exceptions.raven_exceptions import RavenException +from ravendb_embedded import ServerOptions + +from ravendb_test_driver import DriverCloseError, RavenTestDriver, TestServerOptions + + +def _run_in_memory_args(options): + return [arg for arg in options.command_line_args if arg.startswith("--RunInMemory")] + + +class TestOptionNormalization(TestCase): + def test_applies_test_defaults_to_a_plain_server_options(self): + options = RavenTestDriver._normalize_test_server_options(ServerOptions()) + + self.assertEqual(["--RunInMemory=true"], _run_in_memory_args(options)) + self.assertEqual("-c", options.command_line_args[0]) + self.assertTrue(options.command_line_args[1].endswith(".json")) + + def test_is_idempotent(self): + options = TestServerOptions() + RavenTestDriver._normalize_test_server_options(options) + first_pass = list(options.command_line_args) + data_directory = options.data_directory + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual(first_pass, options.command_line_args) + self.assertEqual(data_directory, options.data_directory) + + def test_keeps_a_caller_supplied_run_in_memory_value(self): + options = ServerOptions() + options.command_line_args.append("--RunInMemory=false") + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual(["--RunInMemory=false"], _run_in_memory_args(options)) + + def test_run_in_memory_can_be_switched_off_on_the_driver(self): + class _OnDiskDriver(RavenTestDriver): + run_in_memory = False + + options = _OnDiskDriver._normalize_test_server_options(ServerOptions()) + + self.assertEqual([], _run_in_memory_args(options)) + + def test_does_not_mutate_the_callers_argument_list(self): + options = ServerOptions() + caller_list = options.command_line_args + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual([], caller_list) + self.assertIsNot(caller_list, options.command_line_args) + + def test_redirects_the_packaged_default_data_directory(self): + options = ServerOptions() + packaged_default = options.data_directory + + RavenTestDriver._normalize_test_server_options(options) + + self.assertNotEqual(packaged_default, options.data_directory) + # Logs follow the data directory, so one temp root covers both. + self.assertTrue(options.logs_path.startswith(options.data_directory)) + + def test_leaves_an_explicit_data_directory_alone(self): + options = ServerOptions() + options.data_directory = os.path.join(os.getcwd(), "chosen-by-the-user") + + RavenTestDriver._normalize_test_server_options(options) + + self.assertTrue(options.data_directory.endswith("chosen-by-the-user")) + + def test_rejects_a_secured_server_the_client_cannot_authenticate_to(self): + options = ServerOptions() + options.secured("server.pfx") + + with self.assertRaisesRegex(RavenException, "client certificate"): + RavenTestDriver._normalize_test_server_options(options) + + +class TestServerSelectionPrecedence(TestCase): + def setUp(self): + self.addCleanup(setattr, RavenTestDriver, "_GLOBAL_SERVER_OPTIONS", RavenTestDriver._GLOBAL_SERVER_OPTIONS) + self.addCleanup(setattr, RavenTestDriver, "_EXTERNAL_SERVER_URL", RavenTestDriver._EXTERNAL_SERVER_URL) + previous_url = os.environ.get("RAVENDB_TEST_SERVER_URL") + if previous_url is None: + self.addCleanup(os.environ.pop, "RAVENDB_TEST_SERVER_URL", None) + else: + self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_SERVER_URL", previous_url) + + def test_configure_external_server_wins_over_the_environment(self): + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = "http://from-the-api:8080" + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + self.assertEqual("http://from-the-api:8080", RavenTestDriver._resolve_external_server_url()) + + def test_environment_is_used_when_nothing_was_configured(self): + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = None + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + self.assertEqual("http://from-the-environment:8080", RavenTestDriver._resolve_external_server_url()) + + def test_configure_server_wins_over_the_environment_and_warns(self): + # The environment used to silently redirect a suite pinned to the embedded server onto + # someone else's, where the driver then creates and hard-deletes databases. + RavenTestDriver._EXTERNAL_SERVER_URL = None + RavenTestDriver._GLOBAL_SERVER_OPTIONS = TestServerOptions() + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolved = RavenTestDriver._resolve_external_server_url() + + self.assertIsNone(resolved) + self.assertEqual(1, len(caught)) + self.assertIn("configure_server", str(caught[0].message)) + + +class _FakeSession: + def __init__(self, exists): + self.advanced = SimpleNamespace(exists=lambda key: exists) + self.deleted = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def delete(self, key): + self.deleted.append(key) + + def save_changes(self): + pass + + +class _FakeStore: + def __init__(self, exists=False): + self.database = "test_1" + self.urls = ["http://127.0.0.1:8080"] + self.sessions = [] + self._exists = exists + + def open_session(self): + session = _FakeSession(self._exists) + self.sessions.append(session) + return session + + +class _SilentDriver(RavenTestDriver): + opened = [] + + @staticmethod + def open_browser(url: str) -> None: + _SilentDriver.opened.append(url) + + +class TestWaitForUserToContinueTheTest(TestCase): + def setUp(self): + _SilentDriver.opened = [] + previous = os.environ.get("RAVENDB_TEST_DRIVER_WAIT_FOR_USER") + if previous is None: + self.addCleanup(os.environ.pop, "RAVENDB_TEST_DRIVER_WAIT_FOR_USER", None) + else: + self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_DRIVER_WAIT_FOR_USER", previous) + + def test_environment_kill_switch_skips_the_wait_entirely(self): + os.environ["RAVENDB_TEST_DRIVER_WAIT_FOR_USER"] = "0" + store = _FakeStore() + + _SilentDriver().wait_for_user_to_continue_the_test(store) + + self.assertEqual([], _SilentDriver.opened) + self.assertEqual([], store.sessions) + + def test_times_out_instead_of_hanging_a_ci_job(self): + store = _FakeStore(exists=False) + + with self.assertRaisesRegex(TimeoutException, "Debug/Done"): + _SilentDriver().wait_for_user_to_continue_the_test(store, timeout=timedelta(milliseconds=1)) + + self.assertEqual(1, len(_SilentDriver.opened)) + + def test_deletes_the_marker_and_returns(self): + store = _FakeStore(exists=True) + + _SilentDriver().wait_for_user_to_continue_the_test(store, timeout=timedelta(seconds=5)) + + self.assertEqual(["Debug/Done"], store.sessions[-1].deleted) + + +class TestDriverCloseError(TestCase): + def test_a_raising_callback_is_collected_not_swallowed(self): + driver = RavenTestDriver() + + def explode(_): + raise ValueError("callback blew up") + + driver.on_driver_closed = explode + + with self.assertRaises(DriverCloseError) as caught: + driver.close() + + self.assertEqual(1, len(caught.exception.exceptions)) + self.assertIsInstance(caught.exception.exceptions[0], ValueError) + # Subclasses RuntimeError, so existing handlers keep working. + self.assertIsInstance(caught.exception, RuntimeError) + + +class TestSharedServerTeardown(TestCase): + """Runs last on purpose: it stops the server the rest of the suite shares.""" + + def test_stop_test_server_is_idempotent_and_the_server_comes_back(self): + if RavenTestDriver._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL"): + self.skipTest("attach mode: the driver does not own the server") + + with RavenTestDriver() as driver: + with driver.get_document_store(): + pass + + RavenTestDriver.stop_test_server() + RavenTestDriver.stop_test_server() + + self.assertFalse(RavenTestDriver._TEST_SERVER_STORE.is_value_created) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + session.store({"name": "after restart"}, "people/1") + session.save_changes() + + RavenTestDriver.stop_test_server() From 99e51c8310880744fa5c524520cdb542f144e4a4 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 12:18:03 +0200 Subject: [PATCH 03/32] RavenDB-27141 Document in-memory servers, secured embedded and the Debug/Done contract --- README.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 66685ae..1b8dfb3 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,57 @@ No configuration is needed. The driver starts the framework-dependent server bun Run `dotnet --list-runtimes` and look for `Microsoft.NETCore.App`. Re-check the requirement when upgrading to a new RavenDB minor version. +#### Where test data lives + +Embedded test servers run in memory, so the create-and-delete-a-database cycle behind every +`get_document_store()` call never lands on disk. Only the server log is written, to a scratch +directory the driver removes when the interpreter exits. + +Two consequences worth knowing: + +- A large fixture seeded in `setup_database` is held in RAM rather than spilled to disk. +- Nothing survives a server restart, and there are no files to inspect after a failing run. + +To go back to disk-backed storage, either set the argument yourself, which the driver never +overrides: + +```python +options = TestServerOptions() +options.command_line_args.append("--RunInMemory=false") +options.data_directory = "/path/you/choose" +RavenTestDriver.configure_server(options) +``` + +or switch it off for a whole test class: + +```python +class MyDriver(RavenTestDriver): + run_in_memory = False +``` + +The driver also redirects the data directory when you leave it at the `ravendb-embedded` default, +which otherwise points inside the installed package. Set `data_directory` explicitly and the +driver leaves your path alone. + +Nothing closes the shared test server before interpreter exit. Call +`RavenTestDriver.stop_test_server()` from a session-scoped fixture teardown when you want that +cost inside your test run rather than after the runner prints its summary; the server starts +again on the next `get_document_store()`. + +#### Secured embedded server + +Pass a server certificate together with the client PEM the tests authenticate with, and the driver +wires that client material into every store it hands out: + +```python +options = TestServerOptions() +options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") +RavenTestDriver.configure_server(options) +``` + +The client PEM is required here: a secured server the test client cannot authenticate to is +rejected before the server starts. + Runnable walkthrough: [Lab 02 — isolated embedded databases](labs/02-embedded-per-test.md). ### On-demand self-contained server @@ -63,10 +114,9 @@ Runnable walkthrough: [Lab 02 — isolated embedded databases](labs/02-embedded- Let the driver download, cache, and manage the self-contained build for the current platform: ```python -from ravendb_embedded import ServerOptions -from ravendb_test_driver import RavenTestDriver +from ravendb_test_driver import RavenTestDriver, TestServerOptions -options = ServerOptions() +options = TestServerOptions() options.with_auto_downloaded_server() RavenTestDriver.configure_server(options) @@ -75,6 +125,9 @@ with RavenTestDriver() as driver: ... ``` +`TestServerOptions` is a `ravendb_embedded.ServerOptions` that names the intent. `configure_server` +still accepts a plain `ServerOptions`, and the driver applies the same test defaults either way. + The same test configuration works across supported Windows, Linux, and macOS machines because the operating system and architecture are detected at runtime. The first run downloads 100 MB+; later runs reuse `~/.cache/ravendb-embedded`. Pass `cache_root` to @@ -128,12 +181,18 @@ The equivalent environment variables are: `trust_store_path` or `RAVENDB_TEST_SERVER_CA` is needed when the server's CA is not already trusted by the test machine. +Explicit configuration wins over the environment. If a test calls `configure_server()` and +`RAVENDB_TEST_SERVER_URL` is also set, the environment variable is ignored and a warning is +emitted, because the driver creates and hard-deletes databases on whichever server it uses. To let +the environment pick the server, do not call `configure_server()`. + Runnable walkthrough: [Lab 01 — Docker, Testcontainers, and shared servers](labs/01-attach-to-server.md). ## Test lifecycle -Create a `RavenTestDriver` for the test or fixture, then close every returned store. A context -manager handles both steps: +Create a `RavenTestDriver` for the test or fixture. Closing the driver closes any store you left +open and deletes its database, so nothing leaks if a test throws halfway. Context managers make +both steps explicit: ```python from unittest import TestCase @@ -150,7 +209,12 @@ class TestPeople(TestCase): ``` Each `get_document_store()` call creates a new database. Closing the store deletes it, which keeps -tests independent even when they share one RavenDB server process. +tests independent even when they share one RavenDB server process. Database names are generated +(`test_1`, `test_2`, ...) from a process-wide counter; treat them as opaque and read +`store.database` rather than assuming a name, or pass `database="..."` to pick the stem yourself. + +If closing the driver hits errors, it raises `DriverCloseError`, a `RuntimeError` subclass whose +`exceptions` attribute holds every original exception rather than a joined string. ## Seed data and wait for indexing @@ -165,11 +229,30 @@ class PeopleTestDriver(RavenTestDriver): session.save_changes() ``` +Override `pre_configure_database(self, database_record)` to change the database itself before it is +created, for settings, revisions, expiration, encryption or topology: + +```python +class PeopleTestDriver(RavenTestDriver): + def pre_configure_database(self, database_record): + database_record.settings["Indexing.MapTimeoutInSec"] = "30" +``` + Use `GetDocumentStoreOptions.wait_for_indexing_timeout` when a store should not be returned until -indexing settles, or call `wait_for_indexing(store)` directly. +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. + +## Pausing for manual inspection + +`wait_for_user_to_continue_the_test(store)` prints the Studio URL for that database, opens a +browser, and blocks until a document with the id `Debug/Done` shows up in the database. Store one +from Studio to continue; the driver deletes the marker so a later wait on the same store still +blocks. -`wait_for_user_to_continue_the_test(store)` opens RavenDB Studio and pauses the test for manual -inspection. +The wait is bounded by a five-minute timeout and then raises `TimeoutException`, so a call left in +committed code fails a CI job instead of hanging it. Pass `timeout=None` to wait indefinitely, +which is also what happens automatically when a debugger is attached, or set +`RAVENDB_TEST_DRIVER_WAIT_FOR_USER=0` to skip the wait entirely. Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.md). From 7a6a4edf1406d8bc1ded754823c50a87f7b1f195 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:33:46 +0200 Subject: [PATCH 04/32] RavenDB-27141 Add opt-in caller-name database naming --- ravendb_test_driver/raven_test_driver.py | 39 ++++++++++++++- tests/test_database_naming.py | 61 ++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/test_database_naming.py diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index e3ec301..5afce07 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -1,6 +1,7 @@ import atexit import logging import os +import re import shutil import sys import tempfile @@ -39,11 +40,18 @@ _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_DRIVER_WAIT_FOR_USER" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) +# co_name values that are never a useful database name. +_SYNTHETIC_FRAME_NAMES = frozenset({"", "", "", "", "", ""}) +_DATABASE_NAME_STEM_MAX_LENGTH = 100 + class RavenTestDriver: # Test servers run in memory unless a subclass or a caller's command line says otherwise. run_in_memory: bool = True + # Off by default: switching it on changes every generated database name. + use_caller_name_for_database: bool = False + _TEST_SERVER: EmbeddedServer = EmbeddedServer() _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver.run_server()) _INDEX = 0 @@ -126,9 +134,8 @@ def get_document_store( options: Optional[GetDocumentStoreOptions] = None, database: Optional[str] = None, ) -> DocumentStore: - database = database or "test" options = options or GetDocumentStoreOptions() - name = f"{database}_{RavenTestDriver._next_index()}" + name = self._next_database_name(database) document_store = self._TEST_SERVER_STORE.value database_record = DatabaseRecord(name) @@ -177,6 +184,34 @@ def __close_event_callback(): return store + @classmethod + def _caller_name(cls, depth: int = 3) -> Optional[str]: + """The name of the test that asked for a store, C#'s [CallerMemberName] equivalent. + + sys._getframe, not inspect.stack(): the latter builds FrameInfo records with source + context for every frame on the stack and costs milliseconds per call. + """ + try: + frame_name = sys._getframe(depth).f_code.co_name + except ValueError: # stack is not that deep + return None + + if frame_name in _SYNTHETIC_FRAME_NAMES: + return None + + # Database names are restricted; a co_name is normally already a Python identifier, but + # nothing guarantees it for generated or renamed code objects. + sanitized = re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] + return sanitized or None + + @classmethod + def _next_database_name(cls, database: Optional[str] = None) -> str: + stem = database + if stem is None and cls.use_caller_name_for_database: + stem = cls._caller_name() + + return f"{stem or 'test'}_{cls._next_index()}" + def pre_initialize(self, document_store: DocumentStore) -> None: pass # empty by design diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py new file mode 100644 index 0000000..488a214 --- /dev/null +++ b/tests/test_database_naming.py @@ -0,0 +1,61 @@ +"""Database-name generation: the caller-name opt-in and the process-wide counter.""" + +from unittest import TestCase + +from ravendb_test_driver import RavenTestDriver + + +class _CallerNameDriver(RavenTestDriver): + use_caller_name_for_database = True + + def name_for_test(self): + # Stands in for get_document_store, so _caller_name sees the same call depth. + return self._next_database_name(None) + + +class TestCallerNameOptIn(TestCase): + def test_is_off_by_default(self): + self.assertFalse(RavenTestDriver.use_caller_name_for_database) + self.assertTrue(RavenTestDriver()._next_database_name(None).startswith("test_")) + + def test_uses_the_calling_test_name_when_switched_on(self): + name = _CallerNameDriver().name_for_test() + + self.assertTrue(name.startswith("test_uses_the_calling_test_name_when_switched_on_"), name) + + def test_an_explicit_database_still_wins(self): + name = _CallerNameDriver()._next_database_name("chosen") + + self.assertTrue(name.startswith("chosen_"), name) + + def test_synthetic_frame_names_fall_back_to_test(self): + driver = _CallerNameDriver() + name = (lambda: driver._next_database_name(None))() + + # co_name is here, which is not a usable database name. + self.assertTrue(name.startswith("test_"), name) + + def test_illegal_characters_are_replaced(self): + def _name(): + return RavenTestDriver._caller_name(depth=1) + + _name.__code__ = _name.__code__.replace(co_name="weird name/with:chars") + + self.assertEqual("weird_name_with_chars", _name()) + + def test_caller_name_is_truncated(self): + def _name(): + return RavenTestDriver._caller_name(depth=1) + + _name.__code__ = _name.__code__.replace(co_name="x" * 300) + + self.assertEqual(100, len(_name())) + + +class TestDatabaseNameCounter(TestCase): + def test_counter_is_process_wide_and_monotonic(self): + first = RavenTestDriver()._next_database_name("stem") + second = RavenTestDriver()._next_database_name("stem") + + self.assertNotEqual(first, second) + self.assertLess(int(first.rsplit("_", 1)[1]), int(second.rsplit("_", 1)[1])) From 8b7918fcd03ded64ef51a320b7f95f859faf33bc Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:34:26 +0200 Subject: [PATCH 05/32] RavenDB-27141 Add opt-in per-process unique database names --- ravendb_test_driver/raven_test_driver.py | 18 ++++++++++++- tests/test_database_naming.py | 33 +++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 5afce07..7f192cf 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -38,6 +38,7 @@ _LOGGER = logging.getLogger(__name__) _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_DRIVER_WAIT_FOR_USER" +_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) # co_name values that are never a useful database name. @@ -204,13 +205,28 @@ def _caller_name(cls, depth: int = 3) -> Optional[str]: sanitized = re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] return sanitized or None + @staticmethod + def _environment_flag(name: str) -> bool: + value = os.environ.get(name) + if value is None: + return False + value = value.strip().lower() + return bool(value) and value not in _FALSY_ENVIRONMENT_VALUES + @classmethod def _next_database_name(cls, database: Optional[str] = None) -> str: stem = database if stem is None and cls.use_caller_name_for_database: stem = cls._caller_name() - return f"{stem or 'test'}_{cls._next_index()}" + parts = [stem or "test"] + if cls._environment_flag(_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE): + # The counter alone restarts at 1 in every process, so two runners sharing one + # attached server hand out the same name and delete each other's databases. + parts.append(str(os.getpid())) + parts.append(str(cls._next_index())) + + return "_".join(parts) def pre_initialize(self, document_store: DocumentStore) -> None: pass # empty by design diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py index 488a214..e75ab6e 100644 --- a/tests/test_database_naming.py +++ b/tests/test_database_naming.py @@ -1,5 +1,6 @@ -"""Database-name generation: the caller-name opt-in and the process-wide counter.""" +"""Database-name generation: the caller-name opt-in, per-process uniqueness, and the counter.""" +import os from unittest import TestCase from ravendb_test_driver import RavenTestDriver @@ -52,6 +53,36 @@ def _name(): self.assertEqual(100, len(_name())) +class TestPerProcessUniqueness(TestCase): + def setUp(self): + previous = os.environ.get("RAVENDB_TEST_UNIQUE_DB_NAMES") + if previous is None: + self.addCleanup(os.environ.pop, "RAVENDB_TEST_UNIQUE_DB_NAMES", None) + else: + self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_UNIQUE_DB_NAMES", previous) + + def test_is_off_by_default(self): + os.environ.pop("RAVENDB_TEST_UNIQUE_DB_NAMES", None) + + name = RavenTestDriver()._next_database_name("stem") + + self.assertNotIn(str(os.getpid()), name) + + def test_adds_the_process_id_when_switched_on(self): + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" + + name = RavenTestDriver()._next_database_name("stem") + + self.assertTrue(name.startswith(f"stem_{os.getpid()}_"), name) + + def test_falsy_values_leave_it_off(self): + for value in ("0", "false", "no", "off", ""): + with self.subTest(value=value): + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = value + + self.assertNotIn(str(os.getpid()), RavenTestDriver()._next_database_name("stem")) + + class TestDatabaseNameCounter(TestCase): def test_counter_is_process_wide_and_monotonic(self): first = RavenTestDriver()._next_database_name("stem") From 754014df390de997da408331cb9c879bb1286d4d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:35:00 +0200 Subject: [PATCH 06/32] RavenDB-27141 Add opt-in strict licence checking for test servers --- ravendb_test_driver/raven_test_driver.py | 7 ++++++ tests/test_server_options.py | 32 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 7f192cf..3726a54 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -39,6 +39,7 @@ _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_DRIVER_WAIT_FOR_USER" _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" +_STRICT_LICENSE_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_STRICT_LICENSE" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) # co_name values that are never a useful database name. @@ -425,6 +426,12 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions "authenticate with. Pass client_pem_certificate_path to ServerOptions.secured()." ) + if cls._environment_flag(_STRICT_LICENSE_ENVIRONMENT_VARIABLE): + # C#'s TestServerOptions sets this unconditionally, which makes a licence mandatory to + # run a test suite at all. Opt-in here until that is a product decision; the flag is + # left alone otherwise, so a caller who set it themselves keeps it. + options.licensing.throw_on_invalid_or_missing_license = True + # A local copy: the caller's list is theirs, and from here on there is more than one writer. command_line_args = list(options.command_line_args) diff --git a/tests/test_server_options.py b/tests/test_server_options.py index 03f9ada..0ed3939 100644 --- a/tests/test_server_options.py +++ b/tests/test_server_options.py @@ -90,6 +90,38 @@ def test_rejects_a_secured_server_the_client_cannot_authenticate_to(self): RavenTestDriver._normalize_test_server_options(options) +class TestStrictLicenseOptIn(TestCase): + def setUp(self): + previous = os.environ.get("RAVENDB_TEST_STRICT_LICENSE") + if previous is None: + self.addCleanup(os.environ.pop, "RAVENDB_TEST_STRICT_LICENSE", None) + else: + self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_STRICT_LICENSE", previous) + + def test_is_off_by_default(self): + os.environ.pop("RAVENDB_TEST_STRICT_LICENSE", None) + + options = RavenTestDriver._normalize_test_server_options(TestServerOptions()) + + self.assertFalse(options.licensing.throw_on_invalid_or_missing_license) + + def test_switched_on_by_the_environment(self): + os.environ["RAVENDB_TEST_STRICT_LICENSE"] = "1" + + options = RavenTestDriver._normalize_test_server_options(TestServerOptions()) + + self.assertTrue(options.licensing.throw_on_invalid_or_missing_license) + + def test_a_caller_who_set_it_keeps_it(self): + os.environ.pop("RAVENDB_TEST_STRICT_LICENSE", None) + options = TestServerOptions() + options.licensing.throw_on_invalid_or_missing_license = True + + RavenTestDriver._normalize_test_server_options(options) + + self.assertTrue(options.licensing.throw_on_invalid_or_missing_license) + + class TestServerSelectionPrecedence(TestCase): def setUp(self): self.addCleanup(setattr, RavenTestDriver, "_GLOBAL_SERVER_OPTIONS", RavenTestDriver._GLOBAL_SERVER_OPTIONS) From 89bd13357e0ca899f30049c3a4d63cda194008b0 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:36:42 +0200 Subject: [PATCH 07/32] RavenDB-27141 Make internal helpers private with deprecated aliases --- ravendb_test_driver/raven_test_driver.py | 40 ++++++++++++++++++++---- tests/test_a_secured_attach.py | 4 +-- tests/test_driver_lifecycle.py | 2 +- tests/test_server_options.py | 23 ++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 3726a54..d0e407c 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -55,7 +55,7 @@ class RavenTestDriver: use_caller_name_for_database: bool = False _TEST_SERVER: EmbeddedServer = EmbeddedServer() - _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver.run_server()) + _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver._run_server()) _INDEX = 0 _INDEX_LOCK = threading.Lock() _GLOBAL_SERVER_OPTIONS: Optional[ServerOptions] = None @@ -392,7 +392,7 @@ def close(self) -> None: raise DriverCloseError(exceptions) @staticmethod - def cleanup_temp_dirs(*dirs: str) -> None: + def _cleanup_temp_dirs(*dirs: str) -> None: for _ in range(30): any_failure = False for dir_ in dirs: @@ -408,7 +408,7 @@ def cleanup_temp_dirs(*dirs: str) -> None: time.sleep(0.2) @staticmethod - def default_server_options() -> ServerOptions: + def _default_server_options() -> ServerOptions: return RavenTestDriver._normalize_test_server_options(TestServerOptions()) @classmethod @@ -450,7 +450,7 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions if default_data_directory is not None and options.data_directory == default_data_directory: data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") options.data_directory = data_directory - atexit.register(cls.cleanup_temp_dirs, data_directory) + atexit.register(cls._cleanup_temp_dirs, data_directory) _LOGGER.info("Test server data and logs redirected to %s", data_directory) return options @@ -480,7 +480,7 @@ def _resolve_external_server_url(cls) -> Optional[str]: return environment_url @classmethod - def run_server(cls) -> DocumentStore: + def _run_server(cls) -> DocumentStore: external_url = cls._resolve_external_server_url() if external_url: # Attach to an existing server; do not boot the embedded one (no .NET needed). @@ -537,7 +537,7 @@ def stop_test_server(cls) -> None: try: lazy.value.close() finally: - cls._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver.run_server()) + cls._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) cls._TEST_SERVER.close() @@ -552,3 +552,31 @@ def reset_server_configuration(cls) -> None: cls._EXTERNAL_SERVER_URL = None cls._EXTERNAL_SERVER_CERT = None cls._EXTERNAL_SERVER_TRUST_STORE = None + + # Aliases for helpers that were never meant to be public (C# has no equivalent of any of + # them, the JVM driver keeps all three private). Kept for one release so an upgrade cannot + # break on an AttributeError. + + @staticmethod + def _deprecated_alias(old: str, new: str) -> None: + warnings.warn( + f"RavenTestDriver.{old}() is internal and will be removed in a future release; " + f"use {new}() if you really need it.", + DeprecationWarning, + stacklevel=3, + ) + + @classmethod + def run_server(cls) -> DocumentStore: + cls._deprecated_alias("run_server", "_run_server") + return cls._run_server() + + @staticmethod + def default_server_options() -> ServerOptions: + RavenTestDriver._deprecated_alias("default_server_options", "_default_server_options") + return RavenTestDriver._default_server_options() + + @staticmethod + def cleanup_temp_dirs(*dirs: str) -> None: + RavenTestDriver._deprecated_alias("cleanup_temp_dirs", "_cleanup_temp_dirs") + RavenTestDriver._cleanup_temp_dirs(*dirs) diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py index 4ea90c3..e231180 100644 --- a/tests/test_a_secured_attach.py +++ b/tests/test_a_secured_attach.py @@ -93,7 +93,7 @@ def _reset_driver(): RavenTestDriver._EXTERNAL_SERVER_URL = None RavenTestDriver._EXTERNAL_SERVER_CERT = None RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None - RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver.run_server()) + RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) RavenTestDriver._INDEX = 0 for name in TestSecuredAttach._ENV_NAMES: os.environ.pop(name, None) @@ -148,7 +148,7 @@ def test_https_attach_requires_a_client_certificate(self): try: RavenTestDriver.configure_external_server("https://127.0.0.1:1") with self.assertRaisesRegex(RavenException, "needs a client certificate"): - RavenTestDriver.run_server() + RavenTestDriver._run_server() finally: self._reset_driver() for name, value in original_environment.items(): diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py index 269aeab..0d4e093 100644 --- a/tests/test_driver_lifecycle.py +++ b/tests/test_driver_lifecycle.py @@ -170,7 +170,7 @@ def test_cleanup_returns_as_soon_as_the_directory_is_gone(self): Path(directory, "file.txt").write_text("x", encoding="utf-8") started = time.monotonic() - RavenTestDriver.cleanup_temp_dirs(directory) + RavenTestDriver._cleanup_temp_dirs(directory) elapsed = time.monotonic() - started self.assertFalse(os.path.exists(directory)) diff --git a/tests/test_server_options.py b/tests/test_server_options.py index 0ed3939..443cc1b 100644 --- a/tests/test_server_options.py +++ b/tests/test_server_options.py @@ -4,6 +4,7 @@ """ import os +import tempfile import warnings from datetime import timedelta from types import SimpleNamespace @@ -253,6 +254,28 @@ def explode(_): self.assertIsInstance(caught.exception, RuntimeError) +class TestDeprecatedHelperAliases(TestCase): + def test_default_server_options_alias_still_works(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + options = RavenTestDriver.default_server_options() + + self.assertEqual(["--RunInMemory=true"], _run_in_memory_args(options)) + self.assertEqual(1, len(caught)) + self.assertIs(DeprecationWarning, caught[0].category) + self.assertIn("_default_server_options", str(caught[0].message)) + + def test_cleanup_temp_dirs_alias_still_works(self): + directory = tempfile.mkdtemp() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + RavenTestDriver.cleanup_temp_dirs(directory) + + self.assertFalse(os.path.exists(directory)) + self.assertIs(DeprecationWarning, caught[0].category) + + class TestSharedServerTeardown(TestCase): """Runs last on purpose: it stops the server the rest of the suite shares.""" From a0d7441012c12f10a12dacb099edc72abe08b574 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:38:56 +0200 Subject: [PATCH 08/32] RavenDB-27141 Document opt-in switches and add the 7.2.6 changelog --- CHANGELOG.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 15 +++++ 2 files changed, 189 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1176702 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,174 @@ +# What's new + +## 7.2.6 (unreleased) + +Embedded test servers now run in memory and stop writing into the installed package directory. +Secured embedded servers work end to end, because the driver finally hands its client certificate +to the stores it creates. Databases can be shaped before they are created through a new +`pre_configure_database` hook, named after the calling test, and made unique per process for +parallel runs. Closing a driver that still holds open stores no longer crashes. + +Docs: [RavenDB Python client](https://ravendb.net/docs/article-page/latest/python) · +[labs](labs/README.md) · PyPI: https://pypi.org/project/ravendb-test-driver/7.2.6/ + +### New features + +#### In-memory test servers + +Embedded test servers run with `--RunInMemory=true`, so the create-and-delete-a-database cycle +behind every `get_document_store()` call never touches disk. Only the server log is written, into +a scratch directory removed at interpreter exit. Data and log directories left at the +`ravendb-embedded` default are redirected out of `site-packages`, where test data used to land. + +```python +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +# Back to disk-backed storage, either per driver... +class OnDiskDriver(RavenTestDriver): + run_in_memory = False + + +# ...or by saying so on the command line, which the driver never overrides. +options = TestServerOptions() +options.command_line_args.append("--RunInMemory=false") +options.data_directory = "/path/you/choose" +RavenTestDriver.configure_server(options) +``` + +New class attribute: `RavenTestDriver.run_in_memory` + +#### Secured embedded servers + +A secured embedded server now hands its client certificate and trust store to every store the +driver creates, so the test client can authenticate to the server it just booted. A secured server +configured without a client certificate is rejected before it starts, instead of failing later on +the first request. + +```python +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +options = TestServerOptions() +options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") +RavenTestDriver.configure_server(options) + +with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() +``` + +#### Shape a database before it is created + +`pre_configure_database` runs on the `DatabaseRecord` before `CreateDatabaseOperation` is sent, so +subclasses can set database settings, revisions, expiration, encryption or topology. It matches +`PreConfigureDatabase` in the .NET test driver. + +```python +from ravendb_test_driver import RavenTestDriver + + +class PeopleTestDriver(RavenTestDriver): + def pre_configure_database(self, database_record): + database_record.settings["Indexing.MapTimeoutInSec"] = "30" +``` + +New hook: `RavenTestDriver.pre_configure_database` + +#### Database names that say which test they came from + +Two opt-ins, both off by default because they change every generated database name. The class +attribute names databases after the calling test; the environment variable adds the process id so +parallel runners sharing one attached server stop colliding. + +```python +from ravendb_test_driver import RavenTestDriver + + +class PeopleTestDriver(RavenTestDriver): + use_caller_name_for_database = True # test_stores_a_person_3 instead of test_3 +``` + +```bash +RAVENDB_TEST_UNIQUE_DB_NAMES=1 pytest -n auto +``` + +New class attribute: `RavenTestDriver.use_caller_name_for_database` +New environment variable: `RAVENDB_TEST_UNIQUE_DB_NAMES` + +#### Shared test server teardown + +Nothing used to close the shared test server before interpreter exit, so its shutdown cost landed +after the test runner printed its summary. `stop_test_server()` closes the server and its +server-level store, is idempotent, and the server starts again on the next `get_document_store()`. +Configuration is cleared separately, so freeing resources cannot silently drop a `configure_server` +call. + +```python +from ravendb_test_driver import RavenTestDriver + + +def pytest_sessionfinish(session, exitstatus): + RavenTestDriver.stop_test_server() +``` + +New methods: `RavenTestDriver.stop_test_server`, `RavenTestDriver.reset_server_configuration` + +#### Opt-in strict licence checking + +`RAVENDB_TEST_STRICT_LICENSE=1` makes a test server refuse to start without a valid licence, which +is what the .NET test driver does by default. Off by default here, so existing suites are +unaffected. + +New environment variable: `RAVENDB_TEST_STRICT_LICENSE` + +### API changes and improvements + +- New `TestServerOptions`, a `ravendb_embedded.ServerOptions` that names the intent. Test defaults + are applied to whatever options object the driver is given, so `configure_server` keeps accepting + a plain `ServerOptions` and no call site has to change. +- New `DriverCloseError`, raised by `close()` when teardown hits errors. It subclasses + `RuntimeError`, so existing handlers keep working, and its `exceptions` attribute holds the + original exceptions instead of a joined string. An exception raised by an `on_driver_closed` + callback is collected there too rather than discarding the store-close errors. +- `wait_for_user_to_continue_the_test` takes a `timeout` and defaults to five minutes, then raises + `TimeoutException`, so a call left in committed code fails a CI job instead of hanging it. Pass + `timeout=None` to wait indefinitely, which also happens automatically when a debugger is + attached, or set `RAVENDB_TEST_DRIVER_WAIT_FOR_USER=0` to skip the wait. +- **Behavior change:** explicit configuration now beats the environment. When `configure_server()` + was called and `RAVENDB_TEST_SERVER_URL` is also set, the variable is ignored and a warning is + emitted; the driver creates and hard-deletes databases on whichever server it uses, so a silent + redirect was dangerous. To let the environment pick the server, do not call `configure_server()`. +- **Behavior change:** `wait_for_indexing` keeps waiting while a side-by-side replacement index + exists, matching the .NET driver's index-swap semantics. It used to return early, letting the + next query hit the pre-swap index. +- **Behavior change:** database numbering is process-wide again. Every driver instance used to + restart at `1`, so two live drivers both asked for `test_1`. Read `store.database` rather than + assuming a generated name. +- `open_browser` is an instance method and can be overridden, matching the .NET driver's + `protected virtual OpenBrowser`. +- `run_server`, `default_server_options` and `cleanup_temp_dirs` are now `_run_server`, + `_default_server_options` and `_cleanup_temp_dirs`. The old names still work for one release and + emit a `DeprecationWarning`. + +### Other fixes + +- `close()` no longer raises `RuntimeError: dictionary changed size during iteration` when a store + is still open. It iterates a snapshot, so remaining stores are closed, their databases deleted, + `disposed` set and `on_driver_closed` fired. +- Database-name allocation is thread-safe and no longer shadows the class counter with an instance + attribute. +- `wait_for_user_to_continue_the_test` checks for `Debug/Done` with an existence check instead of + loading and tracking the document, and deletes the marker afterwards so a later wait cannot + return immediately on a stale one. +- A no-leader failure while deleting a test database is ignored, as in the .NET driver. +- `timedelta(0)` is honored instead of being treated as "not set", both for + `GetDocumentStoreOptions.wait_for_indexing_timeout` and for `wait_for_indexing(timeout=...)`. +- The temporary settings file the driver generates is removed at exit. +- `cleanup_temp_dirs` stops treating `shutil.rmtree`'s `None` return as a failure, which cost a + pointless retry pass on every successful cleanup. +- Server option preparation no longer mutates the caller's `command_line_args` list. +- Error messages name the actual Python API (`configure_server`, `get_document_store`) instead of + Java-style names, wrapped exceptions keep their `__cause__`, and a failed `open_browser` reports + the URL it could not open rather than raising an empty `RuntimeError`. A browser that cannot open + on a headless machine is reported instead of ignored. diff --git a/README.md b/README.md index 1b8dfb3..01e1af1 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,21 @@ which is also what happens automatically when a debugger is attached, or set Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.md). +## Opt-in switches + +Defaults are chosen so an existing suite keeps working. These are the knobs worth knowing: + +| Switch | Default | What it does | +|--------|---------|--------------| +| `RavenTestDriver.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on a driver subclass to go back to disk | +| `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | +| `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | +| `RAVENDB_TEST_STRICT_LICENSE` | off | Test servers refuse to start without a valid licence, matching the .NET test driver | +| `RAVENDB_TEST_DRIVER_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | + +Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when +the caller has no usable name, such as a lambda or a module-level call. + ## Labs | Lab | Scenario | Needs system .NET? | From 95bfaeef7f0984a66bc8b1244f0e15f471888aac Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:44:45 +0200 Subject: [PATCH 09/32] RavenDB-27141 Document HTTP traffic inspection instead of porting UseFiddler --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 01e1af1..9719ade 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,32 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when the caller has no usable name, such as a lambda or a module-level call. +## Inspecting HTTP traffic + +The client sends its requests through `requests`, which honors `HTTP_PROXY`, so any interception +proxy works without driver support: + +```bash +HTTP_PROXY=http://127.0.0.1:8080 python -m unittest +``` + +On Windows, proxy bypass rules skip loopback addresses, so traffic to `127.0.0.1` never reaches the +proxy. Bind the test server to the machine name instead, which also needs unsecured access to be +allowed on the private network: + +```python +import socket + +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +options = TestServerOptions() +options.server_url = f"http://{socket.gethostname()}:0" +options.command_line_args.append("--Security.UnsecuredAccessAllowed=PrivateNetwork") +RavenTestDriver.configure_server(options) +``` + +That pair is what `TestServerOptions.UseFiddler()` does in the .NET test driver. + ## Labs | Lab | Scenario | Needs system .NET? | From 0ddc458aa85dc3f744d5bc07abf4769f36865c48 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:44:45 +0200 Subject: [PATCH 10/32] RavenDB-27141 Prepare 7.2.5.post3 release --- CHANGELOG.md | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1176702..7c8fec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # What's new -## 7.2.6 (unreleased) +## 7.2.5.post3 Embedded test servers now run in memory and stop writing into the installed package directory. Secured embedded servers work end to end, because the driver finally hands its client certificate @@ -9,7 +9,7 @@ to the stores it creates. Databases can be shaped before they are created throug parallel runs. Closing a driver that still holds open stores no longer crashes. Docs: [RavenDB Python client](https://ravendb.net/docs/article-page/latest/python) · -[labs](labs/README.md) · PyPI: https://pypi.org/project/ravendb-test-driver/7.2.6/ +[labs](labs/README.md) · PyPI: https://pypi.org/project/ravendb-test-driver/7.2.5.post3/ ### New features diff --git a/setup.py b/setup.py index 4a97f94..ea26fab 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.post2", + version="7.2.5.post3", description="RavenDB package for writing integration tests against RavenDB server", long_description_content_type="text/markdown", long_description=open("README.md").read(), From d136424dfcffacd97151e0c6b9671cdf8441ca9f Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:55:30 +0200 Subject: [PATCH 11/32] RavenDB-27141 Extract shared certificate and driver-reset test helpers --- tests/support.py | 94 ++++++++++++++++++++++++++++++++++ tests/test_a_secured_attach.py | 76 ++------------------------- 2 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 tests/support.py diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..6fb76aa --- /dev/null +++ b/tests/support.py @@ -0,0 +1,94 @@ +"""Shared test helpers: self-signed certificate material and global driver state reset.""" + +import datetime +import ipaddress +import os +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_test_driver import RavenTestDriver + +ENVIRONMENT_NAMES = ("RAVENDB_TEST_SERVER_URL", "RAVENDB_TEST_SERVER_CERT", "RAVENDB_TEST_SERVER_CA") + + +def attach_mode_is_active() -> bool: + """True when the suite is pointed at a server it does not own, so embedded tests must skip.""" + return bool(RavenTestDriver._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL")) + + +def reset_driver() -> None: + """Put the shared server and its configuration back to a pristine state.""" + RavenTestDriver.stop_test_server() + RavenTestDriver.reset_server_configuration() + for name in ENVIRONMENT_NAMES: + os.environ.pop(name, None) + + +def certificates(directory): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_certificate = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates( + b"localhost", + key, + certificate, + None, + serialization.NoEncryption(), + ) + ) + client_pem.write_bytes(key_pem + certificate_pem) + ca_certificate.write_bytes(certificate_pem) + return str(server_pfx), str(client_pem), str(ca_certificate) diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py index e231180..3a41876 100644 --- a/tests/test_a_secured_attach.py +++ b/tests/test_a_secured_attach.py @@ -1,85 +1,15 @@ -import datetime -import ipaddress import os import tempfile from pathlib import Path from unittest import TestCase -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import pkcs12 -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID from ravendb import Lazy from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded import EmbeddedServer, ServerOptions -from ravendb_test_driver import RavenTestDriver - +from tests.support import certificates -def _certificates(directory): - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) - now = datetime.datetime.now(datetime.timezone.utc) - certificate = ( - x509.CertificateBuilder() - .subject_name(name) - .issuer_name(name) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=3650)) - .add_extension( - x509.SubjectAlternativeName( - [ - x509.DNSName("localhost"), - x509.IPAddress(ipaddress.ip_address("127.0.0.1")), - ] - ), - critical=False, - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) - key_pem = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) - - server_pfx = Path(directory, "server.pfx") - client_pem = Path(directory, "client.pem") - ca_certificate = Path(directory, "ca.crt") - server_pfx.write_bytes( - pkcs12.serialize_key_and_certificates( - b"localhost", - key, - certificate, - None, - serialization.NoEncryption(), - ) - ) - client_pem.write_bytes(key_pem + certificate_pem) - ca_certificate.write_bytes(certificate_pem) - return str(server_pfx), str(client_pem), str(ca_certificate) +from ravendb_test_driver import RavenTestDriver class TestSecuredAttach(TestCase): @@ -110,7 +40,7 @@ def _write_and_read(self, database): def test_api_and_environment_credentials_reach_database_store(self): original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} with tempfile.TemporaryDirectory() as directory: - server_pfx, client_pem, ca_certificate = _certificates(directory) + server_pfx, client_pem, ca_certificate = certificates(directory) options = ServerOptions() options.secured( server_pfx, From 8bce1c036e981f889a660a348cffed9c0cdd49ea Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:55:30 +0200 Subject: [PATCH 12/32] RavenDB-27141 Cover error paths and make test-database deletion unit-testable --- ravendb_test_driver/raven_test_driver.py | 30 +++--- tests/test_error_handling.py | 120 +++++++++++++++++++++++ tests/test_server_options.py | 12 +++ 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 tests/test_error_handling.py diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index d0e407c..a58c8e4 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -161,19 +161,9 @@ def __close_event_callback(): except KeyError: return - try: - # database_record.database_name, not store.database: a subclass may have renamed - # the record in pre_configure_database, and the database it created is the one to - # delete. - store.maintenance.server.send(DeleteDatabaseOperation(database_record.database_name, True)) - except (DatabaseDoesNotExistException, NoLoaderException): - pass # ignore - except RavenException as e: - # The client registers the server's NoLeaderException under a misspelled key - # ('NoLoaderException'), so a real no-leader failure arrives as a plain - # RavenException. Drop this branch once the client mapping is fixed. - if "NoLeaderException" not in str(e): - raise + # database_record.database_name, not store.database: a subclass may have renamed the + # record in pre_configure_database, and the database it created is the one to delete. + self._delete_test_database(store, database_record.database_name) store.add_after_close(__close_event_callback) @@ -186,6 +176,20 @@ def __close_event_callback(): return store + @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.""" + try: + store.maintenance.server.send(DeleteDatabaseOperation(database_name, True)) + except (DatabaseDoesNotExistException, NoLoaderException): + pass # already gone, or the cluster has no leader right now + except RavenException as e: + # The client registers the server's NoLeaderException under a misspelled key + # ('NoLoaderException'), so a real no-leader failure arrives as a plain + # RavenException. Drop this branch once the client mapping is fixed. + if "NoLeaderException" not in str(e): + raise + @classmethod def _caller_name(cls, depth: int = 3) -> Optional[str]: """The name of the test that asked for a store, C#'s [CallerMemberName] equivalent. diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..f85fb8a --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,120 @@ +"""Error paths: message wording, swallowed teardown failures, and browser launch failures. + +All hermetic: no server is started. +""" + +import io +import webbrowser +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch + +from ravendb import Lazy +from ravendb.exceptions.cluster import NoLoaderException +from ravendb.exceptions.exceptions import DatabaseDoesNotExistException +from ravendb.exceptions.raven_exceptions import RavenException +from ravendb_embedded import ServerOptions + +from ravendb_test_driver import RavenTestDriver + + +class _StoreThatRaises: + def __init__(self, error): + self.sent = [] + + def send(operation): + self.sent.append(operation) + if error is not None: + raise error + + self.maintenance = SimpleNamespace(server=SimpleNamespace(send=send)) + + +class TestDeleteTestDatabase(TestCase): + def test_deletes_the_database_it_was_given(self): + store = _StoreThatRaises(None) + + RavenTestDriver._delete_test_database(store, "test_1") + + self.assertEqual(1, len(store.sent)) + + def test_a_database_that_is_already_gone_is_not_an_error(self): + RavenTestDriver._delete_test_database(_StoreThatRaises(DatabaseDoesNotExistException("gone")), "test_1") + + def test_a_typed_no_leader_failure_is_ignored(self): + RavenTestDriver._delete_test_database(_StoreThatRaises(NoLoaderException("no leader")), "test_1") + + def test_an_untyped_no_leader_failure_is_ignored(self): + # The client maps the server's NoLeaderException under a misspelled key, so today a real + # no-leader failure arrives as a plain RavenException carrying the name in its message. + error = RavenException("Raven.Client.Exceptions.Cluster.NoLeaderException: no leader elected") + + RavenTestDriver._delete_test_database(_StoreThatRaises(error), "test_1") + + def test_any_other_failure_still_propagates(self): + with self.assertRaises(RavenException): + RavenTestDriver._delete_test_database(_StoreThatRaises(RavenException("disk on fire")), "test_1") + + +class TestConfigurationMessages(TestCase): + def setUp(self): + started = Lazy(lambda: object()) + started.value # force creation, so the driver believes the server is already up + self.addCleanup(setattr, RavenTestDriver, "_TEST_SERVER_STORE", RavenTestDriver._TEST_SERVER_STORE) + RavenTestDriver._TEST_SERVER_STORE = started + + def test_configure_server_names_the_python_api(self): + # The message used to carry the Java driver's camelCase names. + with self.assertRaises(RuntimeError) as caught: + RavenTestDriver.configure_server(ServerOptions()) + + message = str(caught.exception) + self.assertIn("configure_server", message) + self.assertIn("get_document_store", message) + self.assertNotIn("configureServer", message) + self.assertNotIn("getDocumentStore", message) + + def test_configure_external_server_names_the_python_api(self): + with self.assertRaises(RuntimeError) as caught: + RavenTestDriver.configure_external_server("http://localhost:8080") + + message = str(caught.exception) + self.assertIn("configure_external_server", message) + self.assertIn("get_document_store", message) + + +class TestOpenBrowser(TestCase): + def test_a_launch_failure_names_the_url_and_keeps_the_cause(self): + cause = OSError("no display") + + with patch.object(webbrowser, "open", side_effect=cause): + with self.assertRaises(RuntimeError) as caught: + with redirect_stdout(io.StringIO()): + RavenTestDriver().open_browser("http://127.0.0.1:8080/studio") + + self.assertIn("http://127.0.0.1:8080/studio", str(caught.exception)) + self.assertIs(cause, caught.exception.__cause__) + + def test_a_headless_machine_is_reported_rather_than_ignored(self): + # webbrowser.open returns False instead of raising when there is no browser. + output = io.StringIO() + + with patch.object(webbrowser, "open", return_value=False): + with redirect_stdout(output): + RavenTestDriver().open_browser("http://127.0.0.1:8080/studio") + + self.assertIn("No browser could be opened", output.getvalue()) + + def test_open_browser_can_be_overridden(self): + # It is the driver's override hook, the analogue of C#'s protected virtual OpenBrowser. + opened = [] + + class _Driver(RavenTestDriver): + @staticmethod + def open_browser(url): + opened.append(url) + + _Driver().open_browser("http://127.0.0.1:8080/studio") + + self.assertEqual(["http://127.0.0.1:8080/studio"], opened) diff --git a/tests/test_server_options.py b/tests/test_server_options.py index 443cc1b..6bf2fea 100644 --- a/tests/test_server_options.py +++ b/tests/test_server_options.py @@ -9,6 +9,7 @@ from datetime import timedelta from types import SimpleNamespace from unittest import TestCase +from unittest.mock import patch from ravendb.exceptions.exceptions import TimeoutException from ravendb.exceptions.raven_exceptions import RavenException @@ -265,6 +266,17 @@ def test_default_server_options_alias_still_works(self): self.assertIs(DeprecationWarning, caught[0].category) self.assertIn("_default_server_options", str(caught[0].message)) + def test_run_server_alias_still_works(self): + with patch.object(RavenTestDriver, "_run_server", return_value="store") as run_server: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = RavenTestDriver.run_server() + + self.assertEqual("store", result) + self.assertEqual(1, run_server.call_count) + self.assertIs(DeprecationWarning, caught[0].category) + self.assertIn("_run_server", str(caught[0].message)) + def test_cleanup_temp_dirs_alias_still_works(self): directory = tempfile.mkdtemp() From 783ca4073e42ad93e6a5cd2e1c78f6c6247572db Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 13:55:31 +0200 Subject: [PATCH 13/32] RavenDB-27141 Add end-to-end coverage against a real embedded server --- tests/test_driver_lifecycle.py | 10 ++ tests/test_end_to_end.py | 223 +++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 tests/test_end_to_end.py diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py index 0d4e093..59cbcf1 100644 --- a/tests/test_driver_lifecycle.py +++ b/tests/test_driver_lifecycle.py @@ -154,6 +154,16 @@ def test_waits_for_a_side_by_side_replacement_to_be_swapped_in(self): self.assertGreater(admin.polls, 0) + def test_a_zero_timeout_is_honored_instead_of_becoming_sixty_seconds(self): + # `timeout or timedelta(seconds=60)` silently turned an explicit zero into a minute. + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/ByCompany", stale=True)])) + + started = time.monotonic() + with self.assertRaises(TimeoutException): + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(0)) + + self.assertLess(time.monotonic() - started, 1) + def test_disabled_indexes_are_ignored(self): admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/Disabled", stale=True, state=IndexState.DISABLED)])) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000..6cf5e30 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,223 @@ +"""End-to-end checks against a real embedded server. + +The unit tests elsewhere pin behavior with fakes; these boot an actual RavenDB server and assert +on what the server, the filesystem and the process left behind. Everything here is skipped when +the suite is pointed at a server the driver does not own. +""" + +import glob +import os +import subprocess +import sys +import tempfile +from datetime import timedelta +from pathlib import Path +from unittest import TestCase, skipIf + +from ravendb import GetDatabaseNamesOperation +from ravendb.documents.indexes.abstract_index_creation_tasks import AbstractIndexCreationTask + +from ravendb_test_driver import GetDocumentStoreOptions, RavenTestDriver, TestServerOptions +from tests.support import attach_mode_is_active, certificates, reset_driver + +skip_without_own_server = skipIf(attach_mode_is_active(), "attach mode: the driver does not own the server") + + +def _database_names(): + store = RavenTestDriver._TEST_SERVER_STORE.value + return store.maintenance.server.send(GetDatabaseNamesOperation(0, 1000)) + + +class Person: + def __init__(self, Id=None, name=None): + self.Id = Id + self.name = name + + +@skip_without_own_server +class TestDatabaseLifecycle(TestCase): + def test_closing_a_store_deletes_its_database_on_the_server(self): + driver = RavenTestDriver() + store = driver.get_document_store() + database = store.database + + self.assertIn(database, _database_names()) + + store.close() + + self.assertNotIn(database, _database_names()) + driver.close() + + def test_closing_the_driver_deletes_databases_of_stores_left_open(self): + # The contract the README advertises: whatever you forget to close, the driver cleans up. + driver = RavenTestDriver() + first = driver.get_document_store().database + second = driver.get_document_store().database + + self.assertIn(first, _database_names()) + self.assertIn(second, _database_names()) + + driver.close() + + names = _database_names() + self.assertNotIn(first, names) + self.assertNotIn(second, names) + + +@skip_without_own_server +class TestDatabaseNamingAgainstServer(TestCase): + def setUp(self): + self.addCleanup(setattr, RavenTestDriver, "use_caller_name_for_database", False) + previous = os.environ.get("RAVENDB_TEST_UNIQUE_DB_NAMES") + if previous is None: + self.addCleanup(os.environ.pop, "RAVENDB_TEST_UNIQUE_DB_NAMES", None) + else: + self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_UNIQUE_DB_NAMES", previous) + + def test_caller_name_and_process_id_reach_the_created_database(self): + RavenTestDriver.use_caller_name_for_database = True + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + self.assertTrue( + store.database.startswith( + f"test_caller_name_and_process_id_reach_the_created_database_{os.getpid()}_" + ), + store.database, + ) + # The server really has a database under that generated name. + self.assertIn(store.database, _database_names()) + + +@skip_without_own_server +class TestInMemoryStorage(TestCase): + def test_no_database_files_are_written_to_disk(self): + reset_driver() + self.addCleanup(reset_driver) + + with tempfile.TemporaryDirectory() as directory: + options = TestServerOptions() + options.data_directory = directory + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + for i in range(100): + session.store(Person(name=f"person-{i}"), f"people/{i}") + session.save_changes() + + written = [str(path.relative_to(directory)) for path in Path(directory).rglob("*") if path.is_file()] + + # Only the server log survives; the storage engine never touched this directory. + self.assertTrue(written, "expected at least the server log") + self.assertEqual( + [], [name for name in written if name.endswith((".voron", ".journal", ".buffers"))], written + ) + self.assertEqual([], [name for name in written if not name.startswith("Logs")], written) + + +@skip_without_own_server +class TestSecuredEmbeddedServer(TestCase): + def test_driver_authenticates_to_a_secured_embedded_server(self): + # Regression: the driver used to build its stores without the client certificate the + # embedded server was started with, so a secured embedded server was unusable. + reset_driver() + self.addCleanup(reset_driver) + + with tempfile.TemporaryDirectory() as directory: + server_pfx, client_pem, ca_certificate = certificates(directory) + options = TestServerOptions() + options.secured(server_pfx, client_pem, ca_certificate_path=ca_certificate) + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + self.assertTrue(store.urls[0].startswith("https://"), store.urls) + self.assertEqual(client_pem, store.certificate_pem_path) + + with store.open_session() as session: + session.store(Person(name="secured"), "people/1") + session.save_changes() + + with store.open_session() as session: + self.assertEqual("secured", session.load("people/1", Person).name) + + +@skip_without_own_server +class TestWaitForIndexingOption(TestCase): + def test_a_zero_wait_for_indexing_timeout_still_waits(self): + # `if options.wait_for_indexing_timeout:` skipped the wait entirely for timedelta(0). + recorded = [] + + class _RecordingDriver(RavenTestDriver): + @staticmethod + def wait_for_indexing(store, database=None, timeout=None): + recorded.append(timeout) + + with _RecordingDriver() as driver: + with driver.get_document_store(GetDocumentStoreOptions.with_timeout(timedelta(0))): + pass + + self.assertEqual([timedelta(0)], recorded) + + +class People_ByName(AbstractIndexCreationTask): + def __init__(self): + super().__init__() + self.map = "from p in docs.People select new { p.name }" + + +class _SeedingDriver(RavenTestDriver): + def setup_database(self, store) -> None: + store.execute_index(People_ByName()) + with store.open_session() as session: + session.store(Person(name="Seeded"), "people/1") + session.save_changes() + + +@skip_without_own_server +class TestSeedingAndIndexing(TestCase): + def test_seeded_data_is_queryable_through_a_real_index(self): + # wait_for_indexing is unit-tested against canned statistics; this proves it against a + # real indexer, on an in-memory server, through setup_database. + with _SeedingDriver() as driver: + with driver.get_document_store() as store: + driver.wait_for_indexing(store) + + with store.open_session() as session: + hits = list(session.query_index_type(People_ByName, Person).where_equals("name", "Seeded")) + + self.assertEqual(1, len(hits)) + self.assertEqual("Seeded", hits[0].name) + + +@skip_without_own_server +class TestProcessExitCleanup(TestCase): + def test_a_finished_test_run_leaves_no_temporary_files(self): + # atexit can only be observed from the outside, so this runs a real child interpreter. + temporary = tempfile.gettempdir() + data_before = set(glob.glob(os.path.join(temporary, "ravendb-test-driver-*"))) + settings_before = set(glob.glob(os.path.join(temporary, "settings-*.json"))) + + child = subprocess.run( + [ + sys.executable, + "-c", + "from ravendb_test_driver import RavenTestDriver\n" + "with RavenTestDriver() as driver:\n" + " with driver.get_document_store() as store:\n" + " with store.open_session() as session:\n" + " session.store({'name': 'child'}, 'people/1')\n" + " session.save_changes()\n", + ], + capture_output=True, + text=True, + timeout=300, + cwd=str(Path(__file__).resolve().parent.parent), + ) + + self.assertEqual(0, child.returncode, child.stderr) + self.assertEqual(set(), set(glob.glob(os.path.join(temporary, "ravendb-test-driver-*"))) - data_before) + self.assertEqual(set(), set(glob.glob(os.path.join(temporary, "settings-*.json"))) - settings_before) From 70a5c88144eb3a84d1f97f44052ce8936f108035 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 19:15:05 +0200 Subject: [PATCH 14/32] RavenDB-27141 Keep release notes in GitHub Releases instead of a changelog file --- CHANGELOG.md | 174 --------------------------------------------------- 1 file changed, 174 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7c8fec8..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,174 +0,0 @@ -# What's new - -## 7.2.5.post3 - -Embedded test servers now run in memory and stop writing into the installed package directory. -Secured embedded servers work end to end, because the driver finally hands its client certificate -to the stores it creates. Databases can be shaped before they are created through a new -`pre_configure_database` hook, named after the calling test, and made unique per process for -parallel runs. Closing a driver that still holds open stores no longer crashes. - -Docs: [RavenDB Python client](https://ravendb.net/docs/article-page/latest/python) · -[labs](labs/README.md) · PyPI: https://pypi.org/project/ravendb-test-driver/7.2.5.post3/ - -### New features - -#### In-memory test servers - -Embedded test servers run with `--RunInMemory=true`, so the create-and-delete-a-database cycle -behind every `get_document_store()` call never touches disk. Only the server log is written, into -a scratch directory removed at interpreter exit. Data and log directories left at the -`ravendb-embedded` default are redirected out of `site-packages`, where test data used to land. - -```python -from ravendb_test_driver import RavenTestDriver, TestServerOptions - -# Back to disk-backed storage, either per driver... -class OnDiskDriver(RavenTestDriver): - run_in_memory = False - - -# ...or by saying so on the command line, which the driver never overrides. -options = TestServerOptions() -options.command_line_args.append("--RunInMemory=false") -options.data_directory = "/path/you/choose" -RavenTestDriver.configure_server(options) -``` - -New class attribute: `RavenTestDriver.run_in_memory` - -#### Secured embedded servers - -A secured embedded server now hands its client certificate and trust store to every store the -driver creates, so the test client can authenticate to the server it just booted. A secured server -configured without a client certificate is rejected before it starts, instead of failing later on -the first request. - -```python -from ravendb_test_driver import RavenTestDriver, TestServerOptions - -options = TestServerOptions() -options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") -RavenTestDriver.configure_server(options) - -with RavenTestDriver() as driver: - with driver.get_document_store() as store: - with store.open_session() as session: - session.store({"name": "John"}, "people/1") - session.save_changes() -``` - -#### Shape a database before it is created - -`pre_configure_database` runs on the `DatabaseRecord` before `CreateDatabaseOperation` is sent, so -subclasses can set database settings, revisions, expiration, encryption or topology. It matches -`PreConfigureDatabase` in the .NET test driver. - -```python -from ravendb_test_driver import RavenTestDriver - - -class PeopleTestDriver(RavenTestDriver): - def pre_configure_database(self, database_record): - database_record.settings["Indexing.MapTimeoutInSec"] = "30" -``` - -New hook: `RavenTestDriver.pre_configure_database` - -#### Database names that say which test they came from - -Two opt-ins, both off by default because they change every generated database name. The class -attribute names databases after the calling test; the environment variable adds the process id so -parallel runners sharing one attached server stop colliding. - -```python -from ravendb_test_driver import RavenTestDriver - - -class PeopleTestDriver(RavenTestDriver): - use_caller_name_for_database = True # test_stores_a_person_3 instead of test_3 -``` - -```bash -RAVENDB_TEST_UNIQUE_DB_NAMES=1 pytest -n auto -``` - -New class attribute: `RavenTestDriver.use_caller_name_for_database` -New environment variable: `RAVENDB_TEST_UNIQUE_DB_NAMES` - -#### Shared test server teardown - -Nothing used to close the shared test server before interpreter exit, so its shutdown cost landed -after the test runner printed its summary. `stop_test_server()` closes the server and its -server-level store, is idempotent, and the server starts again on the next `get_document_store()`. -Configuration is cleared separately, so freeing resources cannot silently drop a `configure_server` -call. - -```python -from ravendb_test_driver import RavenTestDriver - - -def pytest_sessionfinish(session, exitstatus): - RavenTestDriver.stop_test_server() -``` - -New methods: `RavenTestDriver.stop_test_server`, `RavenTestDriver.reset_server_configuration` - -#### Opt-in strict licence checking - -`RAVENDB_TEST_STRICT_LICENSE=1` makes a test server refuse to start without a valid licence, which -is what the .NET test driver does by default. Off by default here, so existing suites are -unaffected. - -New environment variable: `RAVENDB_TEST_STRICT_LICENSE` - -### API changes and improvements - -- New `TestServerOptions`, a `ravendb_embedded.ServerOptions` that names the intent. Test defaults - are applied to whatever options object the driver is given, so `configure_server` keeps accepting - a plain `ServerOptions` and no call site has to change. -- New `DriverCloseError`, raised by `close()` when teardown hits errors. It subclasses - `RuntimeError`, so existing handlers keep working, and its `exceptions` attribute holds the - original exceptions instead of a joined string. An exception raised by an `on_driver_closed` - callback is collected there too rather than discarding the store-close errors. -- `wait_for_user_to_continue_the_test` takes a `timeout` and defaults to five minutes, then raises - `TimeoutException`, so a call left in committed code fails a CI job instead of hanging it. Pass - `timeout=None` to wait indefinitely, which also happens automatically when a debugger is - attached, or set `RAVENDB_TEST_DRIVER_WAIT_FOR_USER=0` to skip the wait. -- **Behavior change:** explicit configuration now beats the environment. When `configure_server()` - was called and `RAVENDB_TEST_SERVER_URL` is also set, the variable is ignored and a warning is - emitted; the driver creates and hard-deletes databases on whichever server it uses, so a silent - redirect was dangerous. To let the environment pick the server, do not call `configure_server()`. -- **Behavior change:** `wait_for_indexing` keeps waiting while a side-by-side replacement index - exists, matching the .NET driver's index-swap semantics. It used to return early, letting the - next query hit the pre-swap index. -- **Behavior change:** database numbering is process-wide again. Every driver instance used to - restart at `1`, so two live drivers both asked for `test_1`. Read `store.database` rather than - assuming a generated name. -- `open_browser` is an instance method and can be overridden, matching the .NET driver's - `protected virtual OpenBrowser`. -- `run_server`, `default_server_options` and `cleanup_temp_dirs` are now `_run_server`, - `_default_server_options` and `_cleanup_temp_dirs`. The old names still work for one release and - emit a `DeprecationWarning`. - -### Other fixes - -- `close()` no longer raises `RuntimeError: dictionary changed size during iteration` when a store - is still open. It iterates a snapshot, so remaining stores are closed, their databases deleted, - `disposed` set and `on_driver_closed` fired. -- Database-name allocation is thread-safe and no longer shadows the class counter with an instance - attribute. -- `wait_for_user_to_continue_the_test` checks for `Debug/Done` with an existence check instead of - loading and tracking the document, and deletes the marker afterwards so a later wait cannot - return immediately on a stale one. -- A no-leader failure while deleting a test database is ignored, as in the .NET driver. -- `timedelta(0)` is honored instead of being treated as "not set", both for - `GetDocumentStoreOptions.wait_for_indexing_timeout` and for `wait_for_indexing(timeout=...)`. -- The temporary settings file the driver generates is removed at exit. -- `cleanup_temp_dirs` stops treating `shutil.rmtree`'s `None` return as a failure, which cost a - pointless retry pass on every successful cleanup. -- Server option preparation no longer mutates the caller's `command_line_args` list. -- Error messages name the actual Python API (`configure_server`, `get_document_store`) instead of - Java-style names, wrapped exceptions keep their `__cause__`, and a failed `open_browser` reports - the URL it could not open rather than raising an empty `RuntimeError`. A browser that cannot open - on a headless machine is reported instead of ignored. From 3e325df43cd203c127c506d1b74d910033fd5df2 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 19:22:43 +0200 Subject: [PATCH 15/32] RavenDB-27141 Add a secured embedded lab and cover the new hooks in labs 02 and 03 --- .github/workflows/tests.yml | 3 + README.md | 5 +- labs/02-embedded-per-test.md | 18 ++++++ labs/02_embedded_per_test.py | 14 ++++- labs/03-seeding-indexes.md | 20 ++++++- labs/03_seeding_indexes.py | 14 ++++- labs/05-secured-embedded.md | 45 ++++++++++++++ labs/05_secured_embedded.py | 110 +++++++++++++++++++++++++++++++++++ labs/README.md | 1 + 9 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 labs/05-secured-embedded.md create mode 100644 labs/05_secured_embedded.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 323cd08..f2b5c59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,9 @@ jobs: - name: Run Lab 03 (seeding + indexes) run: python labs/03_seeding_indexes.py + - name: Run Lab 05 (secured embedded) + run: python labs/05_secured_embedded.py + attach: # Path C: attach to a RavenDB server running in Docker, with NO .NET installed. Proves the # driver needs no runtime when it does not boot the embedded server. diff --git a/README.md b/README.md index 9719ade..86d2418 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ No configuration is needed. The driver starts the framework-dependent server bun Run `dotnet --list-runtimes` and look for `Microsoft.NETCore.App`. Re-check the requirement when upgrading to a new RavenDB minor version. +Runnable walkthrough: [Lab 02 — isolated embedded databases](labs/02-embedded-per-test.md). + #### Where test data lives Embedded test servers run in memory, so the create-and-delete-a-database cycle behind every @@ -107,7 +109,7 @@ RavenTestDriver.configure_server(options) The client PEM is required here: a secured server the test client cannot authenticate to is rejected before the server starts. -Runnable walkthrough: [Lab 02 — isolated embedded databases](labs/02-embedded-per-test.md). +Runnable walkthrough: [Lab 05 — secured embedded server](labs/05-secured-embedded.md). ### On-demand self-contained server @@ -305,6 +307,7 @@ That pair is what `TestServerOptions.UseFiddler()` does in the .NET test driver. | [02](labs/02-embedded-per-test.md) | Default embedded server and isolated databases | Yes | | [03](labs/03-seeding-indexes.md) | Seed data and wait for real indexing | Yes | | [04](labs/04-embedded-no-dotnet.md) | On-demand self-contained server | No | +| [05](labs/05-secured-embedded.md) | Secured embedded server with client certificates | Yes | The runnable scripts live in this repository rather than `site-packages`. Clone or download the repository, install the package, and run them from the repository root. See the diff --git a/labs/02-embedded-per-test.md b/labs/02-embedded-per-test.md index 4a0c3d4..b511ba7 100644 --- a/labs/02-embedded-per-test.md +++ b/labs/02-embedded-per-test.md @@ -32,6 +32,24 @@ class TestThings(TestCase): Two `get_document_store()` calls give two different databases, so data written to one is invisible to the other. That isolation is what keeps tests independent. +You do not have to close every store yourself. Closing the driver closes the ones you left open and +deletes their databases, so a test that throws halfway still cleans up: + +```python +driver = RavenTestDriver() +store = driver.get_document_store() +driver.close() # store closed, database deleted +``` + +The embedded server is shared by every driver in the process and runs in memory. Nothing closes it +before the interpreter exits, so call `RavenTestDriver.stop_test_server()` from your runner's +teardown when you want that cost inside the run: + +```python +def pytest_sessionfinish(session, exitstatus): + RavenTestDriver.stop_test_server() +``` + ## Takeaway No server to manage in your tests: the driver runs one and gives each test its own database. To diff --git a/labs/02_embedded_per_test.py b/labs/02_embedded_per_test.py index e13b8f1..f4eb7ba 100644 --- a/labs/02_embedded_per_test.py +++ b/labs/02_embedded_per_test.py @@ -24,7 +24,19 @@ def main() -> None: with second.open_session() as session: assert session.load("people/1", dict) is None - print("Lab 02 OK: two stores, two isolated databases, no cross-test leakage.") + # You do not have to close every store yourself: closing the driver closes the ones you left + # open and deletes their databases. + driver = RavenTestDriver() + forgotten = driver.get_document_store() + assert forgotten.database.startswith("test_"), forgotten.database + driver.close() + assert driver.disposed + + # The server is shared by every driver in the process, and nothing closes it before the + # interpreter exits. Call this from your runner's teardown to keep that cost inside the run. + RavenTestDriver.stop_test_server() + + print("Lab 02 OK: two stores, two isolated databases, driver-level cleanup, server stopped.") if __name__ == "__main__": diff --git a/labs/03-seeding-indexes.md b/labs/03-seeding-indexes.md index d98f085..5e7562f 100644 --- a/labs/03-seeding-indexes.md +++ b/labs/03-seeding-indexes.md @@ -43,7 +43,23 @@ RavenDB indexes are updated asynchronously, so right after you write, an index q stale (empty) results. `wait_for_indexing()` blocks until no index is stale, making index-backed assertions deterministic instead of flaky. +## Configure the database itself + +`setup_database()` runs against a database that already exists. To change the database before it is +created, override `pre_configure_database()` and edit the `DatabaseRecord`: settings, revisions, +expiration, encryption and topology are all in reach. + +```python +class SeedingDriver(RavenTestDriver): + def pre_configure_database(self, database_record): # before the database is created + database_record.settings["Indexing.MapTimeoutInSec"] = "30" +``` + +`wait_for_indexing()` also waits for a side-by-side index deployment to finish, so a test that +redeploys an index definition queries the new one rather than the index it replaced. + ## Takeaway -`setup_database()` is the single place to seed data and register indexes for every test database; -`wait_for_indexing()` removes the race between writing and querying an index. +`pre_configure_database()` shapes the database, `setup_database()` is the single place to seed data +and register indexes for every test database, and `wait_for_indexing()` removes the race between +writing and querying an index. diff --git a/labs/03_seeding_indexes.py b/labs/03_seeding_indexes.py index adf7527..acd8c81 100644 --- a/labs/03_seeding_indexes.py +++ b/labs/03_seeding_indexes.py @@ -2,12 +2,14 @@ For: tests that need pre-seeded data and a defined index, and must wait for indexing to settle before asserting. Override setup_database() to seed and create the index for every database the -driver hands out; call wait_for_indexing() before querying so the assertion is not racing the -indexer. Boots the embedded server (needs .NET). +driver hands out, pre_configure_database() to change the database itself before it is created, +and call wait_for_indexing() before querying so the assertion is not racing the indexer. Boots +the embedded server (needs .NET). Run: python labs/03_seeding_indexes.py """ +from ravendb import GetDatabaseRecordOperation from ravendb.documents.indexes.abstract_index_creation_tasks import AbstractIndexCreationTask from ravendb_test_driver import RavenTestDriver @@ -26,6 +28,9 @@ def __init__(self): class SeedingDriver(RavenTestDriver): + def pre_configure_database(self, database_record) -> None: # before the database is created + database_record.settings["Indexing.MapTimeoutInSec"] = "30" + def setup_database(self, store) -> None: # runs for every database the driver creates store.execute_index(People_ByName()) with store.open_session() as session: @@ -36,12 +41,15 @@ def setup_database(self, store) -> None: # runs for every database the driver c def main() -> None: with SeedingDriver() as driver: with driver.get_document_store() as store: + record = store.maintenance.server.send(GetDatabaseRecordOperation(store.database)) + assert record.settings["Indexing.MapTimeoutInSec"] == "30" # set before creation + driver.wait_for_indexing(store) # block until the index is no longer stale with store.open_session() as session: hits = list(session.query_index_type(People_ByName, Person).where_equals("name", "Seeded")) assert len(hits) == 1 and hits[0].name == "Seeded", hits - print("Lab 03 OK: setup_database seeded data + index, wait_for_indexing settled, query returned it.") + print("Lab 03 OK: database pre-configured, data + index seeded, wait_for_indexing settled, query returned it.") if __name__ == "__main__": diff --git a/labs/05-secured-embedded.md b/labs/05-secured-embedded.md new file mode 100644 index 0000000..c8b280f --- /dev/null +++ b/labs/05-secured-embedded.md @@ -0,0 +1,45 @@ +# Lab 05: Secured embedded server, with client-certificate authentication + +**For:** tests that must run against HTTPS and a client certificate, without standing up a server +yourself. Point `ServerOptions.secured()` at a server certificate and the client certificate your +tests authenticate with; the driver passes that client material to every store it hands out. This +path boots the embedded server and needs a matching .NET (see the README). + +## Run it + +```bash +pip install ravendb-test-driver +python labs/05_secured_embedded.py +``` + +The complete example is [`05_secured_embedded.py`](05_secured_embedded.py). The part that matters +is three lines of configuration: + +```python +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +options = TestServerOptions() +options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") +RavenTestDriver.configure_server(options) + +with RavenTestDriver() as driver: + with driver.get_document_store() as store: # https, already authenticated + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() +``` + +- `server.pfx` is what the server presents. RavenDB requires it to carry the `DigitalSignature` + and `KeyEncipherment` key usages, and a matching subject alternative name. +- `client.pem` is what the tests authenticate with. It is required: a secured server the test + client cannot authenticate to is reported before the server starts. +- `ca_certificate_path` becomes the store's trust store, which a self-signed certificate needs. + +The lab generates all three into a temporary directory so it can run anywhere. A real suite points +at its own files instead. + +## Takeaway + +Securing the test server is a configuration change, not a code change: the stores the driver hands +you already carry the client certificate and the trust store. To attach to a secured server you run +yourself, see Lab 01 and `configure_external_server(url, certificate_pem_path=..., trust_store_path=...)`. diff --git a/labs/05_secured_embedded.py b/labs/05_secured_embedded.py new file mode 100644 index 0000000..96b1e41 --- /dev/null +++ b/labs/05_secured_embedded.py @@ -0,0 +1,110 @@ +"""Lab 05: Secured embedded server, with client-certificate authentication. + +For: tests that must run against HTTPS and a client certificate, without standing up a server +yourself. Point ServerOptions.secured() at a server certificate and the client certificate the +tests authenticate with; the driver passes that client material to every store it hands out, so +sessions work with no extra setup. Boots the embedded server and therefore needs a matching .NET. + +Run: python labs/05_secured_embedded.py +""" + +import datetime +import ipaddress +import tempfile +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_test_driver import RavenTestDriver, TestServerOptions + + +def main() -> None: + with tempfile.TemporaryDirectory() as directory: + # In a real suite these are your own files; here they are generated so the lab is runnable. + server_pfx, client_pem, ca_certificate = self_signed_material(directory) + + options = TestServerOptions() + options.secured(server_pfx, client_pem, ca_certificate_path=ca_certificate) + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + assert store.urls[0].startswith("https://"), store.urls + # The driver handed the server's client certificate to the store for you. + assert store.certificate_pem_path == client_pem + + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() + + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "John" + + RavenTestDriver.stop_test_server() + + print("Lab 05 OK: secured embedded server, authenticated session, isolated database.") + + +def self_signed_material(directory: str): + """Write a self-signed server certificate, a client PEM, and the CA to `directory`.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .add_extension( + # RavenDB refuses a server certificate without DigitalSignature and KeyEncipherment. + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_certificate = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates(b"localhost", key, certificate, None, serialization.NoEncryption()) + ) + client_pem.write_bytes(key_pem + certificate_pem) + ca_certificate.write_bytes(certificate_pem) + return str(server_pfx), str(client_pem), str(ca_certificate) + + +if __name__ == "__main__": + main() diff --git a/labs/README.md b/labs/README.md index 706bc3f..f8ad306 100644 --- a/labs/README.md +++ b/labs/README.md @@ -12,6 +12,7 @@ download the repository first, then run them from its root. | [02](02-embedded-per-test.md) | Embedded server, one isolated database per test (the default) | Yes | | [03](03-seeding-indexes.md) | Seed data and query an index (`setup_database`, `wait_for_indexing`) | Yes | | [04](04-embedded-no-dotnet.md) | Self-contained embedded server, downloaded and cached automatically | No | +| [05](05-secured-embedded.md) | Secured embedded server with client-certificate authentication | Yes | For lower-level server configuration, see the [`ravendb-python-embedded`](https://github.com/ravendb/ravendb-python-embedded) labs. From fbcd89e28674d38ad2bfe8eb6e16677b8f43563e Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 19:27:36 +0200 Subject: [PATCH 16/32] RavenDB-27141 Trim comments to what the code cannot say itself --- ravendb_test_driver/raven_test_driver.py | 90 +++++++----------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index a58c8e4..ea309d2 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -42,16 +42,13 @@ _STRICT_LICENSE_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_STRICT_LICENSE" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) -# co_name values that are never a useful database name. _SYNTHETIC_FRAME_NAMES = frozenset({"", "", "", "", "", ""}) _DATABASE_NAME_STEM_MAX_LENGTH = 100 class RavenTestDriver: - # Test servers run in memory unless a subclass or a caller's command line says otherwise. run_in_memory: bool = True - # Off by default: switching it on changes every generated database name. use_caller_name_for_database: bool = False _TEST_SERVER: EmbeddedServer = EmbeddedServer() @@ -77,8 +74,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: @staticmethod def _next_index() -> int: - # Qualified with RavenTestDriver on purpose: 'cls._INDEX += 1' would shadow the class - # attribute with a subclass (or instance) one and every driver would restart at 1. + # Qualified, not cls: 'cls._INDEX += 1' would shadow the counter per subclass. with RavenTestDriver._INDEX_LOCK: RavenTestDriver._INDEX += 1 return RavenTestDriver._INDEX @@ -97,7 +93,7 @@ def _get_empty_settings_file() -> str: temp_file.write(b"{}") temp_file.close() RavenTestDriver._EMPTY_SETTINGS_FILE_NAME = temp_file.name - # Registered before the embedded server's own atexit hook, so it runs after it (LIFO). + # Registered before the server's own atexit hook, so it runs after it (LIFO). atexit.register(RavenTestDriver._remove_empty_settings_file, temp_file.name) return RavenTestDriver._EMPTY_SETTINGS_FILE_NAME @@ -161,8 +157,7 @@ def __close_event_callback(): except KeyError: return - # database_record.database_name, not store.database: a subclass may have renamed the - # record in pre_configure_database, and the database it created is the one to delete. + # The record's name, not store.database: pre_configure_database may rename it. self._delete_test_database(store, database_record.database_name) store.add_after_close(__close_event_callback) @@ -184,18 +179,15 @@ def _delete_test_database(store: DocumentStore, database_name: str) -> None: except (DatabaseDoesNotExistException, NoLoaderException): pass # already gone, or the cluster has no leader right now except RavenException as e: - # The client registers the server's NoLeaderException under a misspelled key - # ('NoLoaderException'), so a real no-leader failure arrives as a plain - # RavenException. Drop this branch once the client mapping is fixed. + # The client maps NoLeaderException under a misspelled key, so it arrives untyped. if "NoLeaderException" not in str(e): raise @classmethod def _caller_name(cls, depth: int = 3) -> Optional[str]: - """The name of the test that asked for a store, C#'s [CallerMemberName] equivalent. + """The calling test's name, C#'s [CallerMemberName] equivalent. - sys._getframe, not inspect.stack(): the latter builds FrameInfo records with source - context for every frame on the stack and costs milliseconds per call. + sys._getframe, not inspect.stack(): the latter costs milliseconds per call. """ try: frame_name = sys._getframe(depth).f_code.co_name @@ -205,8 +197,6 @@ def _caller_name(cls, depth: int = 3) -> Optional[str]: if frame_name in _SYNTHETIC_FRAME_NAMES: return None - # Database names are restricted; a co_name is normally already a Python identifier, but - # nothing guarantees it for generated or renamed code objects. sanitized = re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] return sanitized or None @@ -226,8 +216,7 @@ def _next_database_name(cls, database: Optional[str] = None) -> str: parts = [stem or "test"] if cls._environment_flag(_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE): - # The counter alone restarts at 1 in every process, so two runners sharing one - # attached server hand out the same name and delete each other's databases. + # The counter restarts per process, so runners sharing a server would collide. parts.append(str(os.getpid())) parts.append(str(cls._next_index())) @@ -255,9 +244,7 @@ def wait_for_indexing( while time.monotonic() - start_time < timeout.total_seconds(): database_statistics = admin.send(GetStatisticsOperation()) - # Return only once every applicable index is non-stale AND no side-by-side - # replacement is left, so a pending index swap keeps us waiting instead of - # handing the caller results from the pre-swap index. + # A replacement index holds the wait: until the swap lands, queries hit the old one. pending = [ x for x in database_statistics.indexes @@ -290,9 +277,7 @@ def wait_for_indexing( def _is_debugger_attached() -> bool: """Used only to make the wait unbounded, never to skip it. - sys.gettrace() is deliberately not consulted: coverage.py installs a trace function, so - every pytest-cov run would claim a debugger is attached. A false negative here costs the - default timeout, not a silently skipped inspection point. + sys.gettrace() is not consulted: coverage.py would make every run look debugged. """ debugpy = sys.modules.get("debugpy") if debugpy is not None: @@ -347,8 +332,7 @@ def wait_for_user_to_continue_the_test( time.sleep(0.5) with store.open_session() as session: - # Existence check instead of load(): no document is tracked, and the marker is - # deleted so a later wait on the same store cannot return on a stale one. + # Deleted after the wait, so a later one cannot return on a stale marker. if session.advanced.exists("Debug/Done"): session.delete("Debug/Done") session.save_changes() @@ -363,8 +347,7 @@ def open_browser(url: str) -> None: raise RuntimeError(f"Failed to open a browser at {url}") from e if not opened: - # Headless machines return False rather than raising. The wait itself still works - # through the Debug/Done marker, so this is a note, not a failure. + # Headless machines return False rather than raising; the wait still works. print("No browser could be opened here; use the URL above.") def close(self) -> None: @@ -374,8 +357,7 @@ def close(self) -> None: exceptions = [] try: - # Snapshot: each store's after-close callback pops itself out of _document_stores, - # and mutating the dict we iterate raises RuntimeError from the for statement itself. + # Snapshot: each store's after-close callback pops itself out of this dict. for document_store in list(self._document_stores): try: document_store.close() @@ -385,8 +367,7 @@ def close(self) -> None: self.disposed = True if self.on_driver_closed: - # Collected, not raised on the spot: a raising callback used to discard every - # store-close error gathered above. + # Collected, so a raising callback cannot discard the store-close errors. try: self.on_driver_closed(self) except Exception as e: @@ -402,8 +383,7 @@ def _cleanup_temp_dirs(*dirs: str) -> None: for dir_ in dirs: if not os.path.exists(dir_): continue - # rmtree returns None, so its return value says nothing about success; - # the directory still being there is the only real signal. + # rmtree returns None; the directory still being there is the only signal. shutil.rmtree(dir_, ignore_errors=True) if os.path.exists(dir_): any_failure = True @@ -419,9 +399,7 @@ def _default_server_options() -> ServerOptions: def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions: """Give any ServerOptions the defaults a test server needs. - This is what C# gets from typing ConfigureServer(TestServerOptions), without breaking - callers who pass a plain ServerOptions. Idempotent, and it never overrides a value the - caller set explicitly. + Idempotent, and it never overrides a value the caller set explicitly. """ security = getattr(options, "security", None) if security is not None and not security.client_pem_certificate_path: @@ -431,12 +409,11 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions ) if cls._environment_flag(_STRICT_LICENSE_ENVIRONMENT_VARIABLE): - # C#'s TestServerOptions sets this unconditionally, which makes a licence mandatory to - # run a test suite at all. Opt-in here until that is a product decision; the flag is - # left alone otherwise, so a caller who set it themselves keeps it. + # C# sets this unconditionally, which makes a licence mandatory to run any suite. + # Opt-in until that is a product decision; a caller who set it keeps it. options.licensing.throw_on_invalid_or_missing_license = True - # A local copy: the caller's list is theirs, and from here on there is more than one writer. + # A local copy: the caller's list is theirs, and there is more than one writer now. command_line_args = list(options.command_line_args) settings_file = cls._get_empty_settings_file() @@ -448,8 +425,7 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions options.command_line_args = command_line_args - # Untouched embedded default: the data directory sits inside the installed package, so a - # test run would write into its own dependency tree. Logs follow the data directory. + # The embedded default sits inside the installed package. Logs follow the data directory. default_data_directory = getattr(ServerOptions, "_DEFAULT_DATA_DIRECTORY", None) if default_data_directory is not None and options.data_directory == default_data_directory: data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") @@ -461,11 +437,8 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions @classmethod def _resolve_external_server_url(cls) -> Optional[str]: - """Explicit configuration beats the environment. - - RAVENDB_TEST_SERVER_URL used to win over configure_server(), which silently redirected a - suite pinned to the embedded server onto someone else's - where the driver then creates - and hard-deletes databases. + """Explicit configuration beats the environment, which could otherwise redirect a suite + onto a server where the driver creates and hard-deletes databases. """ if cls._EXTERNAL_SERVER_URL: return cls._EXTERNAL_SERVER_URL @@ -507,8 +480,7 @@ def _run_server(cls) -> DocumentStore: options = cls._GLOBAL_SERVER_OPTIONS or TestServerOptions() cls._normalize_test_server_options(options) except Exception as e: - # Only the option preparation above is wrapped; start_server below raises the - # embedded layer's own, richer error. + # Only option preparation is wrapped; start_server raises the embedded layer's error. raise RavenException(f"Unable to prepare the test server options: {e}", e) from e cls._TEST_SERVER.start_server(options) @@ -517,8 +489,7 @@ def _run_server(cls) -> DocumentStore: store = DocumentStore(url, None) - # A secured embedded server hands its client material to EmbeddedServer.start_server; - # without copying it here the test client cannot authenticate to the server we just booted. + # Without this the test client cannot authenticate to the secured server it just booted. if cls._TEST_SERVER.client_pem_certificate_path: store.certificate_pem_path = cls._TEST_SERVER.client_pem_certificate_path if cls._TEST_SERVER.trust_store_path: @@ -532,9 +503,8 @@ def _run_server(cls) -> DocumentStore: def stop_test_server(cls) -> None: """Close the shared test server and its server-level store. - Nothing else closes them: driver.close() only owns the per-test stores, so without this - the server lives until interpreter exit and its shutdown cost lands after the test runner - has printed its summary. Idempotent, and the server can be started again afterwards. + Nothing else closes them, so without this the cost lands at interpreter exit. + Idempotent, and the server can be started again afterwards. """ lazy = cls._TEST_SERVER_STORE if lazy.is_value_created: @@ -547,19 +517,13 @@ def stop_test_server(cls) -> None: @classmethod def reset_server_configuration(cls) -> None: - """Forget configure_server / configure_external_server, without touching the server. - - Kept separate from stop_test_server on purpose: someone freeing resources should not - silently lose the configuration they registered. - """ + """Forget configure_server / configure_external_server, without touching the server.""" cls._GLOBAL_SERVER_OPTIONS = None cls._EXTERNAL_SERVER_URL = None cls._EXTERNAL_SERVER_CERT = None cls._EXTERNAL_SERVER_TRUST_STORE = None - # Aliases for helpers that were never meant to be public (C# has no equivalent of any of - # them, the JVM driver keeps all three private). Kept for one release so an upgrade cannot - # break on an AttributeError. + # Never meant to be public (private in the JVM driver, absent in C#). Kept for one release. @staticmethod def _deprecated_alias(old: str, new: str) -> None: From b36d8a24c495c7108c9e9c407d2f537214dbe3e7 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 26 Aug 2026 19:48:30 +0200 Subject: [PATCH 17/32] RavenDB-27141 Fix scope defects and consolidate test setup found in review --- README.md | 13 ++-- ravendb_test_driver/options.py | 8 +++ ravendb_test_driver/raven_test_driver.py | 89 +++++++++++++----------- tests/support.py | 8 +++ tests/test_a_secured_attach.py | 19 +---- tests/test_database_naming.py | 30 +++----- tests/test_driver_lifecycle.py | 20 +++++- tests/test_end_to_end.py | 8 +-- tests/test_server_options.py | 51 +++----------- 9 files changed, 113 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 86d2418..538f322 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,12 @@ options.data_directory = "/path/you/choose" RavenTestDriver.configure_server(options) ``` -or switch it off for a whole test class: +or switch it off on the options themselves: ```python -class MyDriver(RavenTestDriver): - run_in_memory = False +options = TestServerOptions() +options.run_in_memory = False +RavenTestDriver.configure_server(options) ``` The driver also redirects the data directory when you leave it at the `ravendb-embedded` default, @@ -254,7 +255,7 @@ blocks. The wait is bounded by a five-minute timeout and then raises `TimeoutException`, so a call left in committed code fails a CI job instead of hanging it. Pass `timeout=None` to wait indefinitely, which is also what happens automatically when a debugger is attached, or set -`RAVENDB_TEST_DRIVER_WAIT_FOR_USER=0` to skip the wait entirely. +`RAVENDB_TEST_WAIT_FOR_USER=0` to skip the wait entirely. Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.md). @@ -264,11 +265,11 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort | Switch | Default | What it does | |--------|---------|--------------| -| `RavenTestDriver.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on a driver subclass to go back to disk | +| `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | | `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | | `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | | `RAVENDB_TEST_STRICT_LICENSE` | off | Test servers refuse to start without a valid licence, matching the .NET test driver | -| `RAVENDB_TEST_DRIVER_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | +| `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when the caller has no usable name, such as a lambda or a module-level call. diff --git a/ravendb_test_driver/options.py b/ravendb_test_driver/options.py index 313ec70..dced796 100644 --- a/ravendb_test_driver/options.py +++ b/ravendb_test_driver/options.py @@ -16,6 +16,14 @@ class TestServerOptions(ServerOptions): test-only defaults that cannot be inferred from a plain `ServerOptions` will live. """ + # pytest collects Test*-named classes it finds in a test module's namespace, imported ones + # included, and warns about the __init__ it cannot construct. + __test__ = False + + def __init__(self) -> None: + super().__init__() + self.run_in_memory: bool = True + class GetDocumentStoreOptions: def __init__(self): diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index ea309d2..70a9901 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -37,7 +37,9 @@ _LOGGER = logging.getLogger(__name__) -_WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_DRIVER_WAIT_FOR_USER" +_WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_WAIT_FOR_USER" +_DEFAULT_WAIT_FOR_USER_TIMEOUT = timedelta(minutes=5) +_UNSET_TIMEOUT = object() _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _STRICT_LICENSE_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_STRICT_LICENSE" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) @@ -47,8 +49,6 @@ class RavenTestDriver: - run_in_memory: bool = True - use_caller_name_for_database: bool = False _TEST_SERVER: EmbeddedServer = EmbeddedServer() @@ -89,9 +89,8 @@ def _remove_empty_settings_file(path: str) -> None: @staticmethod def _get_empty_settings_file() -> str: if not RavenTestDriver._EMPTY_SETTINGS_FILE_NAME: - temp_file = tempfile.NamedTemporaryFile(delete=False, prefix="settings-", suffix=".json") - temp_file.write(b"{}") - temp_file.close() + with tempfile.NamedTemporaryFile(delete=False, prefix="settings-", suffix=".json") as temp_file: + temp_file.write(b"{}") RavenTestDriver._EMPTY_SETTINGS_FILE_NAME = temp_file.name # Registered before the server's own atexit hook, so it runs after it (LIFO). atexit.register(RavenTestDriver._remove_empty_settings_file, temp_file.name) @@ -183,30 +182,33 @@ def _delete_test_database(store: DocumentStore, database_name: str) -> None: if "NoLeaderException" not in str(e): raise - @classmethod - def _caller_name(cls, depth: int = 3) -> Optional[str]: + @staticmethod + def _database_stem(frame_name: str) -> Optional[str]: + if frame_name in _SYNTHETIC_FRAME_NAMES: + return None + + return re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] or None + + @staticmethod + def _caller_name() -> Optional[str]: """The calling test's name, C#'s [CallerMemberName] equivalent. - sys._getframe, not inspect.stack(): the latter costs milliseconds per call. + Walks out of this module instead of counting frames, so adding a driver-internal call + cannot silently rename every database. sys._getframe, not inspect.stack(): the latter + costs milliseconds per call. """ - try: - frame_name = sys._getframe(depth).f_code.co_name - except ValueError: # stack is not that deep - return None - - if frame_name in _SYNTHETIC_FRAME_NAMES: - return None + frame = sys._getframe(1) + while frame is not None and frame.f_globals.get("__name__") == __name__: + frame = frame.f_back - sanitized = re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] - return sanitized or None + return RavenTestDriver._database_stem(frame.f_code.co_name) if frame is not None else None @staticmethod - def _environment_flag(name: str) -> bool: - value = os.environ.get(name) - if value is None: - return False - value = value.strip().lower() - return bool(value) and value not in _FALSY_ENVIRONMENT_VALUES + def _environment_flag(name: str, default: bool = False) -> bool: + value = os.environ.get(name, "").strip().lower() + if not value: + return default + return value not in _FALSY_ENVIRONMENT_VALUES @classmethod def _next_database_name(cls, database: Optional[str] = None) -> str: @@ -299,20 +301,20 @@ def _is_debugger_attached() -> bool: def wait_for_user_to_continue_the_test( self, store: DocumentStore, - timeout: Optional[timedelta] = timedelta(minutes=5), + timeout: Optional[timedelta] = _UNSET_TIMEOUT, ) -> None: """Open Studio and block until a 'Debug/Done' document shows up in this database. - Bounded by `timeout` so a call left in committed code fails a CI job fast instead of - hanging it; pass timeout=None to wait forever. Set RAVENDB_TEST_DRIVER_WAIT_FOR_USER to + Bounded by `timeout`, five minutes by default, so a call left in committed code fails a + CI job fast instead of hanging it. Pass timeout=None to wait forever; with no timeout given + an attached debugger makes the wait unbounded. Set RAVENDB_TEST_WAIT_FOR_USER to 0/false/no/off to skip the wait entirely. """ - environment_value = os.environ.get(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE) - if environment_value is not None and environment_value.strip().lower() in _FALSY_ENVIRONMENT_VALUES: + if not self._environment_flag(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE, default=True): return - if self._is_debugger_attached(): - timeout = None + if timeout is _UNSET_TIMEOUT: + timeout = None if self._is_debugger_attached() else _DEFAULT_WAIT_FOR_USER_TIMEOUT database_name_encoded = quote(store.database, safe="") documents_page = ( @@ -401,7 +403,7 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions Idempotent, and it never overrides a value the caller set explicitly. """ - security = getattr(options, "security", None) + security = options.security if security is not None and not security.client_pem_certificate_path: raise RavenException( "A secured test server needs a client certificate the test client can " @@ -420,14 +422,15 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions if settings_file not in command_line_args: command_line_args[:0] = ["-c", settings_file] - if cls.run_in_memory and not any(arg.startswith("--RunInMemory") for arg in command_line_args): + if getattr(options, "run_in_memory", True) and not any( + arg.startswith("--RunInMemory") for arg in command_line_args + ): command_line_args.append("--RunInMemory=true") options.command_line_args = command_line_args # The embedded default sits inside the installed package. Logs follow the data directory. - default_data_directory = getattr(ServerOptions, "_DEFAULT_DATA_DIRECTORY", None) - if default_data_directory is not None and options.data_directory == default_data_directory: + if options.data_directory == ServerOptions._DEFAULT_DATA_DIRECTORY: data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") options.data_directory = data_directory atexit.register(cls._cleanup_temp_dirs, data_directory) @@ -479,6 +482,8 @@ def _run_server(cls) -> DocumentStore: try: options = cls._GLOBAL_SERVER_OPTIONS or TestServerOptions() cls._normalize_test_server_options(options) + except RavenException: + raise # already explains itself; a second wrapper would only hide it except Exception as e: # Only option preparation is wrapped; start_server raises the embedded layer's error. raise RavenException(f"Unable to prepare the test server options: {e}", e) from e @@ -506,22 +511,22 @@ def stop_test_server(cls) -> None: Nothing else closes them, so without this the cost lands at interpreter exit. Idempotent, and the server can be started again afterwards. """ - lazy = cls._TEST_SERVER_STORE + lazy = RavenTestDriver._TEST_SERVER_STORE if lazy.is_value_created: try: lazy.value.close() finally: - cls._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) + RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) - cls._TEST_SERVER.close() + RavenTestDriver._TEST_SERVER.close() @classmethod def reset_server_configuration(cls) -> None: """Forget configure_server / configure_external_server, without touching the server.""" - cls._GLOBAL_SERVER_OPTIONS = None - cls._EXTERNAL_SERVER_URL = None - cls._EXTERNAL_SERVER_CERT = None - cls._EXTERNAL_SERVER_TRUST_STORE = None + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = None + RavenTestDriver._EXTERNAL_SERVER_CERT = None + RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None # Never meant to be public (private in the JVM driver, absent in C#). Kept for one release. diff --git a/tests/support.py b/tests/support.py index 6fb76aa..37555ff 100644 --- a/tests/support.py +++ b/tests/support.py @@ -4,6 +4,7 @@ import ipaddress import os from pathlib import Path +from unittest.mock import patch from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -21,6 +22,13 @@ def attach_mode_is_active() -> bool: return bool(RavenTestDriver._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL")) +def isolate_environment(test) -> None: + """Restore os.environ exactly as it was once `test` finishes.""" + patcher = patch.dict(os.environ) + patcher.start() + test.addCleanup(patcher.stop) + + def reset_driver() -> None: """Put the shared server and its configuration back to a pristine state.""" RavenTestDriver.stop_test_server() diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py index 3a41876..757862d 100644 --- a/tests/test_a_secured_attach.py +++ b/tests/test_a_secured_attach.py @@ -3,30 +3,17 @@ from pathlib import Path from unittest import TestCase -from ravendb import Lazy from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded import EmbeddedServer, ServerOptions -from tests.support import certificates +from tests.support import ENVIRONMENT_NAMES, certificates, reset_driver from ravendb_test_driver import RavenTestDriver class TestSecuredAttach(TestCase): - _ENV_NAMES = ("RAVENDB_TEST_SERVER_URL", "RAVENDB_TEST_SERVER_CERT", "RAVENDB_TEST_SERVER_CA") - - @staticmethod - def _reset_driver(): - lazy = RavenTestDriver._TEST_SERVER_STORE - if lazy.is_value_created: - lazy.value.close() - RavenTestDriver._EXTERNAL_SERVER_URL = None - RavenTestDriver._EXTERNAL_SERVER_CERT = None - RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None - RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) - RavenTestDriver._INDEX = 0 - for name in TestSecuredAttach._ENV_NAMES: - os.environ.pop(name, None) + _ENV_NAMES = ENVIRONMENT_NAMES + _reset_driver = staticmethod(reset_driver) def _write_and_read(self, database): with RavenTestDriver() as driver: diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py index e75ab6e..3a38347 100644 --- a/tests/test_database_naming.py +++ b/tests/test_database_naming.py @@ -4,15 +4,12 @@ from unittest import TestCase from ravendb_test_driver import RavenTestDriver +from tests.support import isolate_environment class _CallerNameDriver(RavenTestDriver): use_caller_name_for_database = True - def name_for_test(self): - # Stands in for get_document_store, so _caller_name sees the same call depth. - return self._next_database_name(None) - class TestCallerNameOptIn(TestCase): def test_is_off_by_default(self): @@ -20,7 +17,7 @@ def test_is_off_by_default(self): self.assertTrue(RavenTestDriver()._next_database_name(None).startswith("test_")) def test_uses_the_calling_test_name_when_switched_on(self): - name = _CallerNameDriver().name_for_test() + name = _CallerNameDriver()._next_database_name(None) self.assertTrue(name.startswith("test_uses_the_calling_test_name_when_switched_on_"), name) @@ -37,29 +34,18 @@ def test_synthetic_frame_names_fall_back_to_test(self): self.assertTrue(name.startswith("test_"), name) def test_illegal_characters_are_replaced(self): - def _name(): - return RavenTestDriver._caller_name(depth=1) - - _name.__code__ = _name.__code__.replace(co_name="weird name/with:chars") - - self.assertEqual("weird_name_with_chars", _name()) - - def test_caller_name_is_truncated(self): - def _name(): - return RavenTestDriver._caller_name(depth=1) + self.assertEqual("weird_name_with_chars", RavenTestDriver._database_stem("weird name/with:chars")) - _name.__code__ = _name.__code__.replace(co_name="x" * 300) + def test_a_long_name_is_truncated(self): + self.assertEqual(100, len(RavenTestDriver._database_stem("x" * 300))) - self.assertEqual(100, len(_name())) + def test_synthetic_names_have_no_stem(self): + self.assertIsNone(RavenTestDriver._database_stem("")) class TestPerProcessUniqueness(TestCase): def setUp(self): - previous = os.environ.get("RAVENDB_TEST_UNIQUE_DB_NAMES") - if previous is None: - self.addCleanup(os.environ.pop, "RAVENDB_TEST_UNIQUE_DB_NAMES", None) - else: - self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_UNIQUE_DB_NAMES", previous) + isolate_environment(self) def test_is_off_by_default(self): os.environ.pop("RAVENDB_TEST_UNIQUE_DB_NAMES", None) diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py index 59cbcf1..d95ec05 100644 --- a/tests/test_driver_lifecycle.py +++ b/tests/test_driver_lifecycle.py @@ -17,7 +17,7 @@ from ravendb.documents.operations.statistics import IndexInformation from ravendb.exceptions.exceptions import TimeoutException -from ravendb_test_driver import RavenTestDriver +from ravendb_test_driver import DriverCloseError, RavenTestDriver class TestDriverClose(TestCase): @@ -52,6 +52,24 @@ def test_closing_the_driver_twice_is_a_no_op(self): self.assertTrue(driver.disposed) +class TestDriverCloseError(TestCase): + def test_a_raising_callback_is_collected_not_swallowed(self): + driver = RavenTestDriver() + + def explode(_): + raise ValueError("callback blew up") + + driver.on_driver_closed = explode + + with self.assertRaises(DriverCloseError) as caught: + driver.close() + + self.assertEqual(1, len(caught.exception.exceptions)) + self.assertIsInstance(caught.exception.exceptions[0], ValueError) + # Subclasses RuntimeError, so existing handlers keep working. + self.assertIsInstance(caught.exception, RuntimeError) + + class TestDatabaseNameAllocation(TestCase): def test_two_drivers_get_distinct_database_names(self): # self._INDEX += 1 read the class attribute and wrote an instance one, so every diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 6cf5e30..e45daee 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -18,7 +18,7 @@ from ravendb.documents.indexes.abstract_index_creation_tasks import AbstractIndexCreationTask from ravendb_test_driver import GetDocumentStoreOptions, RavenTestDriver, TestServerOptions -from tests.support import attach_mode_is_active, certificates, reset_driver +from tests.support import attach_mode_is_active, certificates, isolate_environment, reset_driver skip_without_own_server = skipIf(attach_mode_is_active(), "attach mode: the driver does not own the server") @@ -68,11 +68,7 @@ def test_closing_the_driver_deletes_databases_of_stores_left_open(self): class TestDatabaseNamingAgainstServer(TestCase): def setUp(self): self.addCleanup(setattr, RavenTestDriver, "use_caller_name_for_database", False) - previous = os.environ.get("RAVENDB_TEST_UNIQUE_DB_NAMES") - if previous is None: - self.addCleanup(os.environ.pop, "RAVENDB_TEST_UNIQUE_DB_NAMES", None) - else: - self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_UNIQUE_DB_NAMES", previous) + isolate_environment(self) def test_caller_name_and_process_id_reach_the_created_database(self): RavenTestDriver.use_caller_name_for_database = True diff --git a/tests/test_server_options.py b/tests/test_server_options.py index 6bf2fea..b041559 100644 --- a/tests/test_server_options.py +++ b/tests/test_server_options.py @@ -15,7 +15,8 @@ from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded import ServerOptions -from ravendb_test_driver import DriverCloseError, RavenTestDriver, TestServerOptions +from ravendb_test_driver import RavenTestDriver, TestServerOptions +from tests.support import attach_mode_is_active, isolate_environment def _run_in_memory_args(options): @@ -49,11 +50,11 @@ def test_keeps_a_caller_supplied_run_in_memory_value(self): self.assertEqual(["--RunInMemory=false"], _run_in_memory_args(options)) - def test_run_in_memory_can_be_switched_off_on_the_driver(self): - class _OnDiskDriver(RavenTestDriver): - run_in_memory = False + def test_run_in_memory_can_be_switched_off_on_the_options(self): + options = TestServerOptions() + options.run_in_memory = False - options = _OnDiskDriver._normalize_test_server_options(ServerOptions()) + RavenTestDriver._normalize_test_server_options(options) self.assertEqual([], _run_in_memory_args(options)) @@ -94,11 +95,7 @@ def test_rejects_a_secured_server_the_client_cannot_authenticate_to(self): class TestStrictLicenseOptIn(TestCase): def setUp(self): - previous = os.environ.get("RAVENDB_TEST_STRICT_LICENSE") - if previous is None: - self.addCleanup(os.environ.pop, "RAVENDB_TEST_STRICT_LICENSE", None) - else: - self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_STRICT_LICENSE", previous) + isolate_environment(self) def test_is_off_by_default(self): os.environ.pop("RAVENDB_TEST_STRICT_LICENSE", None) @@ -128,11 +125,7 @@ class TestServerSelectionPrecedence(TestCase): def setUp(self): self.addCleanup(setattr, RavenTestDriver, "_GLOBAL_SERVER_OPTIONS", RavenTestDriver._GLOBAL_SERVER_OPTIONS) self.addCleanup(setattr, RavenTestDriver, "_EXTERNAL_SERVER_URL", RavenTestDriver._EXTERNAL_SERVER_URL) - previous_url = os.environ.get("RAVENDB_TEST_SERVER_URL") - if previous_url is None: - self.addCleanup(os.environ.pop, "RAVENDB_TEST_SERVER_URL", None) - else: - self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_SERVER_URL", previous_url) + isolate_environment(self) def test_configure_external_server_wins_over_the_environment(self): RavenTestDriver._GLOBAL_SERVER_OPTIONS = None @@ -206,14 +199,10 @@ def open_browser(url: str) -> None: class TestWaitForUserToContinueTheTest(TestCase): def setUp(self): _SilentDriver.opened = [] - previous = os.environ.get("RAVENDB_TEST_DRIVER_WAIT_FOR_USER") - if previous is None: - self.addCleanup(os.environ.pop, "RAVENDB_TEST_DRIVER_WAIT_FOR_USER", None) - else: - self.addCleanup(os.environ.__setitem__, "RAVENDB_TEST_DRIVER_WAIT_FOR_USER", previous) + isolate_environment(self) def test_environment_kill_switch_skips_the_wait_entirely(self): - os.environ["RAVENDB_TEST_DRIVER_WAIT_FOR_USER"] = "0" + os.environ["RAVENDB_TEST_WAIT_FOR_USER"] = "0" store = _FakeStore() _SilentDriver().wait_for_user_to_continue_the_test(store) @@ -237,24 +226,6 @@ def test_deletes_the_marker_and_returns(self): self.assertEqual(["Debug/Done"], store.sessions[-1].deleted) -class TestDriverCloseError(TestCase): - def test_a_raising_callback_is_collected_not_swallowed(self): - driver = RavenTestDriver() - - def explode(_): - raise ValueError("callback blew up") - - driver.on_driver_closed = explode - - with self.assertRaises(DriverCloseError) as caught: - driver.close() - - self.assertEqual(1, len(caught.exception.exceptions)) - self.assertIsInstance(caught.exception.exceptions[0], ValueError) - # Subclasses RuntimeError, so existing handlers keep working. - self.assertIsInstance(caught.exception, RuntimeError) - - class TestDeprecatedHelperAliases(TestCase): def test_default_server_options_alias_still_works(self): with warnings.catch_warnings(record=True) as caught: @@ -292,7 +263,7 @@ class TestSharedServerTeardown(TestCase): """Runs last on purpose: it stops the server the rest of the suite shares.""" def test_stop_test_server_is_idempotent_and_the_server_comes_back(self): - if RavenTestDriver._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL"): + if attach_mode_is_active(): self.skipTest("attach mode: the driver does not own the server") with RavenTestDriver() as driver: From bd5c9a3af4cbcdfc8cc166d6ce9deff5b70cddfd Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 12:32:05 +0200 Subject: [PATCH 18/32] RavenDB-27141 Document the override hooks, name the indexing default, make open_browser an instance method --- ravendb_test_driver/raven_test_driver.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 70a9901..2ec9cec 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -39,6 +39,7 @@ _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_WAIT_FOR_USER" _DEFAULT_WAIT_FOR_USER_TIMEOUT = timedelta(minutes=5) +_DEFAULT_WAIT_FOR_INDEXING_TIMEOUT = timedelta(seconds=60) _UNSET_TIMEOUT = object() _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _STRICT_LICENSE_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_STRICT_LICENSE" @@ -225,13 +226,13 @@ def _next_database_name(cls, database: Optional[str] = None) -> str: return "_".join(parts) def pre_initialize(self, document_store: DocumentStore) -> None: - pass # empty by design + """Override to configure the store before initialize() runs on it. Does nothing here.""" def pre_configure_database(self, database_record: DatabaseRecord) -> None: - pass # empty by design + """Override to change the record before the database is created. Does nothing here.""" def setup_database(self, document_store: DocumentStore) -> None: - pass # empty by design + """Override to create indexes or seed data on the initialized store. Does nothing here.""" @staticmethod def wait_for_indexing( @@ -239,7 +240,7 @@ def wait_for_indexing( database: Optional[str] = None, timeout: Optional[timedelta] = None, ) -> None: - timeout = timeout if timeout is not None else timedelta(seconds=60) # Default timeout + timeout = timeout if timeout is not None else _DEFAULT_WAIT_FOR_INDEXING_TIMEOUT admin = store.maintenance.for_database(database) start_time = time.monotonic() @@ -340,8 +341,7 @@ def wait_for_user_to_continue_the_test( session.save_changes() break - @staticmethod - def open_browser(url: str) -> None: + def open_browser(self, url: str) -> None: print(url) try: opened = webbrowser.open(url) From c1536b69844c8c156a087b5094ab3f14c8d74833 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 12:32:38 +0200 Subject: [PATCH 19/32] RavenDB-27141 Drop dead scaffolding in close and privatize the configuration reset --- ravendb_test_driver/raven_test_driver.py | 20 +++++++++----------- tests/support.py | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 2ec9cec..697bf20 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -353,20 +353,18 @@ def open_browser(self, url: str) -> None: print("No browser could be opened here; use the URL above.") def close(self) -> None: - if getattr(self, "disposed", False): + if self.disposed: return + self.disposed = True exceptions = [] - try: - # Snapshot: each store's after-close callback pops itself out of this dict. - for document_store in list(self._document_stores): - try: - document_store.close() - except Exception as e: - exceptions.append(e) - finally: - self.disposed = True + # Snapshot: each store's after-close callback pops itself out of this dict. + for document_store in list(self._document_stores): + try: + document_store.close() + except Exception as e: + exceptions.append(e) if self.on_driver_closed: # Collected, so a raising callback cannot discard the store-close errors. @@ -521,7 +519,7 @@ def stop_test_server(cls) -> None: RavenTestDriver._TEST_SERVER.close() @classmethod - def reset_server_configuration(cls) -> None: + def _reset_server_configuration(cls) -> None: """Forget configure_server / configure_external_server, without touching the server.""" RavenTestDriver._GLOBAL_SERVER_OPTIONS = None RavenTestDriver._EXTERNAL_SERVER_URL = None diff --git a/tests/support.py b/tests/support.py index 37555ff..fac4b98 100644 --- a/tests/support.py +++ b/tests/support.py @@ -32,7 +32,7 @@ def isolate_environment(test) -> None: def reset_driver() -> None: """Put the shared server and its configuration back to a pristine state.""" RavenTestDriver.stop_test_server() - RavenTestDriver.reset_server_configuration() + RavenTestDriver._reset_server_configuration() for name in ENVIRONMENT_NAMES: os.environ.pop(name, None) From a49c584dbdd21366656488f6ee7af1f6f4827985 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 12:37:06 +0200 Subject: [PATCH 20/32] RavenDB-27141 Give the server bootstrap one store builder and two named modes --- ravendb_test_driver/raven_test_driver.py | 121 +++++++++++++---------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 697bf20..febff20 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -142,11 +142,12 @@ def get_document_store( create_database_operation = CreateDatabaseOperation(database_record) document_store.maintenance.server.send(create_database_operation) - store = DocumentStore(document_store.urls, name) - if document_store.certificate_pem_path: - store.certificate_pem_path = document_store.certificate_pem_path - if document_store.trust_store_path: - store.trust_store_path = document_store.trust_store_path + store = self._store_with_credentials( + document_store.urls, + name, + document_store.certificate_pem_path, + document_store.trust_store_path, + ) self.pre_initialize(store) store.initialize() @@ -395,8 +396,8 @@ def _cleanup_temp_dirs(*dirs: str) -> None: def _default_server_options() -> ServerOptions: return RavenTestDriver._normalize_test_server_options(TestServerOptions()) - @classmethod - def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions: + @staticmethod + def _normalize_test_server_options(options: ServerOptions) -> ServerOptions: """Give any ServerOptions the defaults a test server needs. Idempotent, and it never overrides a value the caller set explicitly. @@ -408,7 +409,7 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions "authenticate with. Pass client_pem_certificate_path to ServerOptions.secured()." ) - if cls._environment_flag(_STRICT_LICENSE_ENVIRONMENT_VARIABLE): + if RavenTestDriver._environment_flag(_STRICT_LICENSE_ENVIRONMENT_VARIABLE): # C# sets this unconditionally, which makes a licence mandatory to run any suite. # Opt-in until that is a product decision; a caller who set it keeps it. options.licensing.throw_on_invalid_or_missing_license = True @@ -416,7 +417,7 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions # A local copy: the caller's list is theirs, and there is more than one writer now. command_line_args = list(options.command_line_args) - settings_file = cls._get_empty_settings_file() + settings_file = RavenTestDriver._get_empty_settings_file() if settings_file not in command_line_args: command_line_args[:0] = ["-c", settings_file] @@ -431,21 +432,21 @@ def _normalize_test_server_options(cls, options: ServerOptions) -> ServerOptions if options.data_directory == ServerOptions._DEFAULT_DATA_DIRECTORY: data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") options.data_directory = data_directory - atexit.register(cls._cleanup_temp_dirs, data_directory) + atexit.register(RavenTestDriver._cleanup_temp_dirs, data_directory) _LOGGER.info("Test server data and logs redirected to %s", data_directory) return options - @classmethod - def _resolve_external_server_url(cls) -> Optional[str]: + @staticmethod + def _resolve_external_server_url() -> Optional[str]: """Explicit configuration beats the environment, which could otherwise redirect a suite onto a server where the driver creates and hard-deletes databases. """ - if cls._EXTERNAL_SERVER_URL: - return cls._EXTERNAL_SERVER_URL + if RavenTestDriver._EXTERNAL_SERVER_URL: + return RavenTestDriver._EXTERNAL_SERVER_URL environment_url = os.environ.get("RAVENDB_TEST_SERVER_URL") - if environment_url and cls._GLOBAL_SERVER_OPTIONS is not None: + if environment_url and RavenTestDriver._GLOBAL_SERVER_OPTIONS is not None: warnings.warn( f"Ignoring RAVENDB_TEST_SERVER_URL={environment_url!r} because configure_server() " "was called explicitly. Drop that call to attach to the server from the " @@ -457,51 +458,67 @@ def _resolve_external_server_url(cls) -> Optional[str]: return environment_url - @classmethod - def _run_server(cls) -> DocumentStore: - external_url = cls._resolve_external_server_url() - if external_url: - # Attach to an existing server; do not boot the embedded one (no .NET needed). - certificate = cls._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") - trust_store = cls._EXTERNAL_SERVER_TRUST_STORE or os.environ.get("RAVENDB_TEST_SERVER_CA") - if external_url.lower().startswith("https") and not certificate: - raise RavenException( - f"Attaching to a secured server ({external_url}) needs a client certificate; pass " - "configure_external_server(url, certificate_pem_path=...) or set RAVENDB_TEST_SERVER_CERT." - ) - store = DocumentStore(external_url, None) - if certificate: - store.certificate_pem_path = certificate - if trust_store: - store.trust_store_path = trust_store - store.initialize() - return store + @staticmethod + def _store_with_credentials( + urls, + database: Optional[str], + certificate_pem_path: Optional[str], + trust_store_path: Optional[str], + ) -> DocumentStore: + """Every store the driver hands out carries the credentials of the server it talks to.""" + store = DocumentStore(urls, database) + if certificate_pem_path: + store.certificate_pem_path = certificate_pem_path + if trust_store_path: + store.trust_store_path = trust_store_path + return store + + @staticmethod + def _attach_to_external_server(url: str) -> DocumentStore: + """Build a store against a server somebody else runs. Nothing is booted, so no .NET is needed.""" + certificate = RavenTestDriver._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") + trust_store = RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE or os.environ.get("RAVENDB_TEST_SERVER_CA") + + if url.lower().startswith("https") and not certificate: + raise RavenException( + f"Attaching to a secured server ({url}) needs a client certificate; pass " + "configure_external_server(url, certificate_pem_path=...) or set RAVENDB_TEST_SERVER_CERT." + ) + store = RavenTestDriver._store_with_credentials(url, None, certificate, trust_store) + store.initialize() + return store + + @staticmethod + def _boot_embedded_server() -> DocumentStore: try: - options = cls._GLOBAL_SERVER_OPTIONS or TestServerOptions() - cls._normalize_test_server_options(options) + options = RavenTestDriver._GLOBAL_SERVER_OPTIONS or TestServerOptions() + RavenTestDriver._normalize_test_server_options(options) except RavenException: raise # already explains itself; a second wrapper would only hide it except Exception as e: # Only option preparation is wrapped; start_server raises the embedded layer's error. raise RavenException(f"Unable to prepare the test server options: {e}", e) from e - cls._TEST_SERVER.start_server(options) - - url = cls._TEST_SERVER.get_server_uri() - - store = DocumentStore(url, None) - - # Without this the test client cannot authenticate to the secured server it just booted. - if cls._TEST_SERVER.client_pem_certificate_path: - store.certificate_pem_path = cls._TEST_SERVER.client_pem_certificate_path - if cls._TEST_SERVER.trust_store_path: - store.trust_store_path = cls._TEST_SERVER.trust_store_path + RavenTestDriver._TEST_SERVER.start_server(options) + store = RavenTestDriver._store_with_credentials( + RavenTestDriver._TEST_SERVER.get_server_uri(), + None, + RavenTestDriver._TEST_SERVER.client_pem_certificate_path, + RavenTestDriver._TEST_SERVER.trust_store_path, + ) store.initialize() - return store + @staticmethod + def _run_server() -> DocumentStore: + external_url = RavenTestDriver._resolve_external_server_url() + if external_url: + return RavenTestDriver._attach_to_external_server(external_url) + + return RavenTestDriver._boot_embedded_server() + @classmethod def stop_test_server(cls) -> None: """Close the shared test server and its server-level store. @@ -537,10 +554,10 @@ def _deprecated_alias(old: str, new: str) -> None: stacklevel=3, ) - @classmethod - def run_server(cls) -> DocumentStore: - cls._deprecated_alias("run_server", "_run_server") - return cls._run_server() + @staticmethod + def run_server() -> DocumentStore: + RavenTestDriver._deprecated_alias("run_server", "_run_server") + return RavenTestDriver._run_server() @staticmethod def default_server_options() -> ServerOptions: From 8fce25dce84c83a29c91e402efe0a45305c6d78b Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 12:37:06 +0200 Subject: [PATCH 21/32] RavenDB-27141 Use the shared environment helper in the secured attach test --- tests/test_a_secured_attach.py | 58 ++++++++++++++-------------------- tests/test_basic.py | 1 - 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py index 757862d..f500352 100644 --- a/tests/test_a_secured_attach.py +++ b/tests/test_a_secured_attach.py @@ -6,14 +6,15 @@ from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded import EmbeddedServer, ServerOptions -from tests.support import ENVIRONMENT_NAMES, certificates, reset_driver - from ravendb_test_driver import RavenTestDriver +from tests.support import certificates, isolate_environment, reset_driver class TestSecuredAttach(TestCase): - _ENV_NAMES = ENVIRONMENT_NAMES - _reset_driver = staticmethod(reset_driver) + def setUp(self): + isolate_environment(self) + reset_driver() + self.addCleanup(reset_driver) def _write_and_read(self, database): with RavenTestDriver() as driver: @@ -25,7 +26,6 @@ def _write_and_read(self, database): self.assertEqual(database, session.load("people/1", dict)["name"]) def test_api_and_environment_credentials_reach_database_store(self): - original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} with tempfile.TemporaryDirectory() as directory: server_pfx, client_pem, ca_certificate = certificates(directory) options = ServerOptions() @@ -37,37 +37,25 @@ def test_api_and_environment_credentials_reach_database_store(self): options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) - try: - with EmbeddedServer() as server: - server.start_server(options) + with EmbeddedServer() as server: + server.start_server(options) + + RavenTestDriver.configure_external_server( + server.get_server_uri(), + certificate_pem_path=client_pem, + trust_store_path=ca_certificate, + ) + self._write_and_read("configured") - RavenTestDriver.configure_external_server( - server.get_server_uri(), - certificate_pem_path=client_pem, - trust_store_path=ca_certificate, - ) - self._write_and_read("configured") - self._reset_driver() + reset_driver() - os.environ["RAVENDB_TEST_SERVER_URL"] = server.get_server_uri() - os.environ["RAVENDB_TEST_SERVER_CERT"] = client_pem - os.environ["RAVENDB_TEST_SERVER_CA"] = ca_certificate - self._write_and_read("environment") - finally: - self._reset_driver() - for name, value in original_environment.items(): - if value is not None: - os.environ[name] = value + os.environ["RAVENDB_TEST_SERVER_URL"] = server.get_server_uri() + os.environ["RAVENDB_TEST_SERVER_CERT"] = client_pem + os.environ["RAVENDB_TEST_SERVER_CA"] = ca_certificate + self._write_and_read("environment") def test_https_attach_requires_a_client_certificate(self): - original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} - self._reset_driver() - try: - RavenTestDriver.configure_external_server("https://127.0.0.1:1") - with self.assertRaisesRegex(RavenException, "needs a client certificate"): - RavenTestDriver._run_server() - finally: - self._reset_driver() - for name, value in original_environment.items(): - if value is not None: - os.environ[name] = value + RavenTestDriver.configure_external_server("https://127.0.0.1:1") + + with self.assertRaisesRegex(RavenException, "needs a client certificate"): + RavenTestDriver._run_server() diff --git a/tests/test_basic.py b/tests/test_basic.py index 91a74b0..0549663 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -49,7 +49,6 @@ def setup_database(self, document_store) -> None: class TestSetupDatabaseHook(TestCase): def test_setup_database_hook_seeds_new_store(self): - # The driver's setup_database hook should run for each store it hands out. driver = _SeedingTestDriver() with driver.get_document_store() as store: with store.open_session() as session: From 7e94fa4af32a808e9be78356f3fdfd9badbf8b5d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 13:39:39 +0200 Subject: [PATCH 22/32] RavenDB-27141 Cover the attach path locally with a second embedded server --- tests/test_attach.py | 66 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/tests/test_attach.py b/tests/test_attach.py index c1ac567..7c30588 100644 --- a/tests/test_attach.py +++ b/tests/test_attach.py @@ -1,31 +1,77 @@ -"""Attach path (no embedded boot, no .NET): point RAVENDB_TEST_SERVER_URL at a running server. +"""Attach path: the driver talks to a server it does not own and does not boot. -Skips when unset, unless RAVENDB_TEST_REQUIRE_ATTACH=1 (CI) makes a missing URL fail loudly. +Where the server comes from, in order: + 1. RAVENDB_TEST_SERVER_URL, which is how CI runs this with no .NET installed at all. + 2. A second embedded server this module starts itself, so the attach path is covered on a + developer machine without Docker. That fallback needs .NET, which the attach mode itself + does not; only the URL is borrowed from it. +Skipped when neither is available, unless RAVENDB_TEST_REQUIRE_ATTACH=1 (CI) makes it fail loudly. """ import os from unittest import TestCase -from ravendb_test_driver import RavenTestDriver +from ravendb_embedded import EmbeddedServer + +from ravendb_test_driver import RavenTestDriver, TestServerOptions +from tests.support import isolate_environment, reset_driver -SERVER_URL = os.environ.get("RAVENDB_TEST_SERVER_URL") _REQUIRE = os.environ.get("RAVENDB_TEST_REQUIRE_ATTACH") == "1" class TestAttachToExternalServer(TestCase): + server_url = None + _owned_server = None + + @classmethod + def setUpClass(cls): + cls.server_url = os.environ.get("RAVENDB_TEST_SERVER_URL") + if cls.server_url: + return + + if _REQUIRE: + raise AssertionError("set RAVENDB_TEST_SERVER_URL to a running server to run the attach test") + + reset_driver() + try: + cls._owned_server = EmbeddedServer() + cls._owned_server.start_server(RavenTestDriver._normalize_test_server_options(TestServerOptions())) + cls.server_url = cls._owned_server.get_server_uri() + except Exception as e: # no .NET runtime, or the server refused to start + cls._owned_server = None + raise TestCase.skipTest(cls, f"no server to attach to: {e}") from e + + @classmethod + def tearDownClass(cls): + if cls._owned_server is not None: + cls._owned_server.close() + cls._owned_server = None + def setUp(self): - if not SERVER_URL: - message = "set RAVENDB_TEST_SERVER_URL to a running server to run the attach test" - if _REQUIRE: - self.fail(message) - self.skipTest(message) + isolate_environment(self) + reset_driver() + self.addCleanup(reset_driver) + os.environ["RAVENDB_TEST_SERVER_URL"] = self.server_url def test_attach_gives_isolated_database(self): driver = RavenTestDriver() with driver.get_document_store() as store: - self.assertIn(SERVER_URL.rstrip("/"), store.urls[0]) + self.assertIn(self.server_url.rstrip("/"), store.urls[0]) with store.open_session() as session: session.store({"name": "attached"}, "people/1") session.save_changes() with store.open_session() as session: self.assertEqual("attached", session.load("people/1", dict)["name"]) + + def test_the_attached_server_keeps_running_when_the_driver_closes(self): + # The driver owns the databases it creates, never the server somebody else runs. + first = RavenTestDriver() + with first.get_document_store() as store: + database = store.database + + RavenTestDriver.stop_test_server() + + second = RavenTestDriver() + with second.get_document_store() as store: + self.assertNotEqual(database, store.database) + self.assertIn(self.server_url.rstrip("/"), store.urls[0]) From 9db9aa117d8216ebffd64e48be5de744cbd8ddc3 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 14:14:41 +0200 Subject: [PATCH 23/32] RavenDB-27141 Document strict licensing as an options switch, not just an environment one --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 538f322..531e35e 100644 --- a/README.md +++ b/README.md @@ -268,9 +268,24 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort | `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | | `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | | `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | -| `RAVENDB_TEST_STRICT_LICENSE` | off | Test servers refuse to start without a valid licence, matching the .NET test driver | +| `TestServerOptions.licensing.throw_on_invalid_or_missing_license` | `False` | Test servers refuse to start without a valid licence, matching the .NET test driver | +| `RAVENDB_TEST_STRICT_LICENSE` | off | The same switch from outside the code, for a CI job that wants a strict run | | `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | +Anything describing the server itself belongs on the options object; the environment variables exist +so a CI job can flip a switch without editing test code. Strict licensing has both, because it is a +property of the server *and* something a pipeline wants to turn on for one run: + +```python +options = TestServerOptions() +options.licensing.throw_on_invalid_or_missing_license = True +options.licensing.license_path = "license.json" +RavenTestDriver.configure_server(options) +``` + +The environment variable only ever turns it on, so options you configured yourself are never +overridden. + Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when the caller has no usable name, such as a lambda or a module-level call. From 1c69de216a9662c125644bb7644bd47756d3aa0e Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 14:23:22 +0200 Subject: [PATCH 24/32] RavenDB-27141 Leave licensing alone: drop the strict-licence opt-in --- README.md | 15 +------------ ravendb_test_driver/raven_test_driver.py | 6 ----- tests/test_server_options.py | 28 ------------------------ 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/README.md b/README.md index 531e35e..9cf90c9 100644 --- a/README.md +++ b/README.md @@ -268,23 +268,10 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort | `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | | `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | | `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | -| `TestServerOptions.licensing.throw_on_invalid_or_missing_license` | `False` | Test servers refuse to start without a valid licence, matching the .NET test driver | -| `RAVENDB_TEST_STRICT_LICENSE` | off | The same switch from outside the code, for a CI job that wants a strict run | | `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | Anything describing the server itself belongs on the options object; the environment variables exist -so a CI job can flip a switch without editing test code. Strict licensing has both, because it is a -property of the server *and* something a pipeline wants to turn on for one run: - -```python -options = TestServerOptions() -options.licensing.throw_on_invalid_or_missing_license = True -options.licensing.license_path = "license.json" -RavenTestDriver.configure_server(options) -``` - -The environment variable only ever turns it on, so options you configured yourself are never -overridden. +so a CI job can flip a switch without editing test code. Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when the caller has no usable name, such as a lambda or a module-level call. diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index febff20..8aa8b1b 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -42,7 +42,6 @@ _DEFAULT_WAIT_FOR_INDEXING_TIMEOUT = timedelta(seconds=60) _UNSET_TIMEOUT = object() _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" -_STRICT_LICENSE_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_STRICT_LICENSE" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) _SYNTHETIC_FRAME_NAMES = frozenset({"", "", "", "", "", ""}) @@ -409,11 +408,6 @@ def _normalize_test_server_options(options: ServerOptions) -> ServerOptions: "authenticate with. Pass client_pem_certificate_path to ServerOptions.secured()." ) - if RavenTestDriver._environment_flag(_STRICT_LICENSE_ENVIRONMENT_VARIABLE): - # C# sets this unconditionally, which makes a licence mandatory to run any suite. - # Opt-in until that is a product decision; a caller who set it keeps it. - options.licensing.throw_on_invalid_or_missing_license = True - # A local copy: the caller's list is theirs, and there is more than one writer now. command_line_args = list(options.command_line_args) diff --git a/tests/test_server_options.py b/tests/test_server_options.py index b041559..5759713 100644 --- a/tests/test_server_options.py +++ b/tests/test_server_options.py @@ -93,34 +93,6 @@ def test_rejects_a_secured_server_the_client_cannot_authenticate_to(self): RavenTestDriver._normalize_test_server_options(options) -class TestStrictLicenseOptIn(TestCase): - def setUp(self): - isolate_environment(self) - - def test_is_off_by_default(self): - os.environ.pop("RAVENDB_TEST_STRICT_LICENSE", None) - - options = RavenTestDriver._normalize_test_server_options(TestServerOptions()) - - self.assertFalse(options.licensing.throw_on_invalid_or_missing_license) - - def test_switched_on_by_the_environment(self): - os.environ["RAVENDB_TEST_STRICT_LICENSE"] = "1" - - options = RavenTestDriver._normalize_test_server_options(TestServerOptions()) - - self.assertTrue(options.licensing.throw_on_invalid_or_missing_license) - - def test_a_caller_who_set_it_keeps_it(self): - os.environ.pop("RAVENDB_TEST_STRICT_LICENSE", None) - options = TestServerOptions() - options.licensing.throw_on_invalid_or_missing_license = True - - RavenTestDriver._normalize_test_server_options(options) - - self.assertTrue(options.licensing.throw_on_invalid_or_missing_license) - - class TestServerSelectionPrecedence(TestCase): def setUp(self): self.addCleanup(setattr, RavenTestDriver, "_GLOBAL_SERVER_OPTIONS", RavenTestDriver._GLOBAL_SERVER_OPTIONS) From 161f5c99cde6943eecae34b462f0ef1f93bb67df Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 14:28:50 +0200 Subject: [PATCH 25/32] RavenDB-27141 Stop the lifecycle examples from contradicting the lifecycle contract --- README.md | 28 ++++++++++++++++++++-------- labs/02-embedded-per-test.md | 26 +++++++++++++++++--------- labs/02_embedded_per_test.py | 12 ++++++------ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9cf90c9..d8fe902 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,9 @@ Runnable walkthrough: [Lab 01 — Docker, Testcontainers, and shared servers](la ## Test lifecycle -Create a `RavenTestDriver` for the test or fixture. Closing the driver closes any store you left -open and deletes its database, so nothing leaks if a test throws halfway. Context managers make -both steps explicit: +Create a `RavenTestDriver` for the test or fixture and register its cleanup once. Closing the driver +closes any store you left open and deletes its database, so nothing leaks if a test throws halfway +and you never have to close a store yourself: ```python from unittest import TestCase @@ -203,12 +203,24 @@ from ravendb_test_driver import RavenTestDriver class TestPeople(TestCase): + def setUp(self): + self.driver = RavenTestDriver() + self.addCleanup(self.driver.close) # the only cleanup line you need + def test_stores_a_person(self): - with RavenTestDriver() as driver: - with driver.get_document_store() as store: - with store.open_session() as session: - session.store({"name": "John"}, "people/1") - session.save_changes() + store = self.driver.get_document_store() + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() +``` + +Closing stores yourself is still fine, and it is what you want when one test creates several +databases and the order they go away in matters: + +```python +with RavenTestDriver() as driver: + with driver.get_document_store() as store: + ... ``` Each `get_document_store()` call creates a new database. Closing the store deletes it, which keeps diff --git a/labs/02-embedded-per-test.md b/labs/02-embedded-per-test.md index b511ba7..ffac38e 100644 --- a/labs/02-embedded-per-test.md +++ b/labs/02-embedded-per-test.md @@ -21,26 +21,34 @@ from ravendb_test_driver import RavenTestDriver class TestThings(TestCase): def setUp(self): self.driver = RavenTestDriver() + self.addCleanup(self.driver.close) # the only cleanup line you need def test_it(self): - with self.driver.get_document_store() as store: # fresh isolated database - with store.open_session() as session: - session.store({"name": "John"}, "people/1") - session.save_changes() + store = self.driver.get_document_store() # fresh isolated database, never closed by hand + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() ``` Two `get_document_store()` calls give two different databases, so data written to one is invisible to the other. That isolation is what keeps tests independent. -You do not have to close every store yourself. Closing the driver closes the ones you left open and -deletes their databases, so a test that throws halfway still cleans up: +You do not have to close the stores. Closing the driver closes whatever is still open and deletes +those databases, so registering the driver's cleanup once covers every test in the class, including +the ones that throw halfway through. + +Outside a test class, a `with` block does the same thing: ```python -driver = RavenTestDriver() -store = driver.get_document_store() -driver.close() # store closed, database deleted +with RavenTestDriver() as driver: + store = driver.get_document_store() + ... # no store.close() anywhere +# leaving the block closed the store and deleted its database ``` +Closing stores yourself is still fine, and it is what you want when a single test creates several +databases and you care about the order they go away in. + The embedded server is shared by every driver in the process and runs in memory. Nothing closes it before the interpreter exits, so call `RavenTestDriver.stop_test_server()` from your runner's teardown when you want that cost inside the run: diff --git a/labs/02_embedded_per_test.py b/labs/02_embedded_per_test.py index f4eb7ba..96ddab9 100644 --- a/labs/02_embedded_per_test.py +++ b/labs/02_embedded_per_test.py @@ -24,12 +24,12 @@ def main() -> None: with second.open_session() as session: assert session.load("people/1", dict) is None - # You do not have to close every store yourself: closing the driver closes the ones you left - # open and deletes their databases. - driver = RavenTestDriver() - forgotten = driver.get_document_store() - assert forgotten.database.startswith("test_"), forgotten.database - driver.close() + # You do not have to close the stores. Leaving the driver's `with` block closes whatever is + # still open and deletes those databases, so a test that throws halfway still cleans up. + with RavenTestDriver() as driver: + forgotten = driver.get_document_store() # no forgotten.close() anywhere + assert forgotten.database.startswith("test_"), forgotten.database + assert driver.disposed # The server is shared by every driver in the process, and nothing closes it before the From 48ff88ef4522eb84a20ea144a46be11bf4d61374 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 15:21:06 +0200 Subject: [PATCH 26/32] RavenDB-27141 Detect synthetic frame names by shape instead of listing them --- ravendb_test_driver/raven_test_driver.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 8aa8b1b..48c3507 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -44,7 +44,6 @@ _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) -_SYNTHETIC_FRAME_NAMES = frozenset({"", "", "", "", "", ""}) _DATABASE_NAME_STEM_MAX_LENGTH = 100 @@ -185,7 +184,9 @@ def _delete_test_database(store: DocumentStore, database_name: str) -> None: @staticmethod def _database_stem(frame_name: str) -> Optional[str]: - if frame_name in _SYNTHETIC_FRAME_NAMES: + # CPython wraps every synthetic code-object name in angle brackets (, , + # , and the comprehensions before 3.12 inlined them). No identifier can. + if frame_name.startswith("<"): return None return re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] or None From 497a2284d1c4242d06b06f0bf6910b4a2286f398 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 16:21:31 +0200 Subject: [PATCH 27/32] RavenDB-27141 Wait for the user with no timeout unless one is given --- README.md | 7 ++--- ravendb_test_driver/raven_test_driver.py | 37 +++--------------------- 2 files changed, 7 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d8fe902..7699038 100644 --- a/README.md +++ b/README.md @@ -264,10 +264,9 @@ browser, and blocks until a document with the id `Debug/Done` shows up in the da from Studio to continue; the driver deletes the marker so a later wait on the same store still blocks. -The wait is bounded by a five-minute timeout and then raises `TimeoutException`, so a call left in -committed code fails a CI job instead of hanging it. Pass `timeout=None` to wait indefinitely, -which is also what happens automatically when a debugger is attached, or set -`RAVENDB_TEST_WAIT_FOR_USER=0` to skip the wait entirely. +The wait is unbounded, because you are the one looking at Studio. Pass a `timeout` to bound it and +get a `TimeoutException` instead. A CI job protects itself from a call left in committed code with +`RAVENDB_TEST_WAIT_FOR_USER=0`, which skips the wait entirely. Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.md). diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 48c3507..cf4749d 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -38,9 +38,7 @@ _LOGGER = logging.getLogger(__name__) _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_WAIT_FOR_USER" -_DEFAULT_WAIT_FOR_USER_TIMEOUT = timedelta(minutes=5) _DEFAULT_WAIT_FOR_INDEXING_TIMEOUT = timedelta(seconds=60) -_UNSET_TIMEOUT = object() _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) @@ -277,47 +275,20 @@ def wait_for_indexing( raise TimeoutException(f"The indexes stayed stale for more than {timeout}. {all_index_errors_text}") - @staticmethod - def _is_debugger_attached() -> bool: - """Used only to make the wait unbounded, never to skip it. - - sys.gettrace() is not consulted: coverage.py would make every run look debugged. - """ - debugpy = sys.modules.get("debugpy") - if debugpy is not None: - try: - if debugpy.is_client_connected(): - return True - except Exception: # pragma: no cover - debugpy internals - pass - - pydevd = sys.modules.get("pydevd") - if pydevd is not None: - try: - return pydevd.get_global_debugger() is not None - except Exception: # pragma: no cover - pydevd internals - pass - - return False - def wait_for_user_to_continue_the_test( self, store: DocumentStore, - timeout: Optional[timedelta] = _UNSET_TIMEOUT, + timeout: Optional[timedelta] = None, ) -> None: """Open Studio and block until a 'Debug/Done' document shows up in this database. - Bounded by `timeout`, five minutes by default, so a call left in committed code fails a - CI job fast instead of hanging it. Pass timeout=None to wait forever; with no timeout given - an attached debugger makes the wait unbounded. Set RAVENDB_TEST_WAIT_FOR_USER to - 0/false/no/off to skip the wait entirely. + Waits as long as it takes, because a human is looking at Studio. Pass a `timeout` to + bound it, and set RAVENDB_TEST_WAIT_FOR_USER to 0/false/no/off to skip the wait + entirely, which is how a CI job protects itself from a call left in committed code. """ if not self._environment_flag(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE, default=True): return - if timeout is _UNSET_TIMEOUT: - timeout = None if self._is_debugger_attached() else _DEFAULT_WAIT_FOR_USER_TIMEOUT - database_name_encoded = quote(store.database, safe="") documents_page = ( f"{store.urls[0]}/studio/index.html#databases/documents?&database={database_name_encoded}&withStop=true" From db02942ace94dc401cab10b1e0450b8cbd12372a Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 16:24:14 +0200 Subject: [PATCH 28/32] RavenDB-27141 Put the indexing default in the signature and stop trimming database names --- README.md | 4 ++-- ravendb_test_driver/raven_test_driver.py | 8 ++------ tests/test_database_naming.py | 3 --- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7699038..fbc53c1 100644 --- a/README.md +++ b/README.md @@ -284,8 +284,8 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort Anything describing the server itself belongs on the options object; the environment variables exist so a CI job can flip a switch without editing test code. -Caller-name databases are sanitized to `[A-Za-z0-9_.-]` and truncated, and fall back to `test` when -the caller has no usable name, such as a lambda or a module-level call. +Caller-name databases are sanitized to `[A-Za-z0-9_.-]`, and fall back to `test` when the caller has +no usable name, such as a lambda or a module-level call. ## Inspecting HTTP traffic diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index cf4749d..869e493 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -38,12 +38,9 @@ _LOGGER = logging.getLogger(__name__) _WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_WAIT_FOR_USER" -_DEFAULT_WAIT_FOR_INDEXING_TIMEOUT = timedelta(seconds=60) _UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" _FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) -_DATABASE_NAME_STEM_MAX_LENGTH = 100 - class RavenTestDriver: use_caller_name_for_database: bool = False @@ -187,7 +184,7 @@ def _database_stem(frame_name: str) -> Optional[str]: if frame_name.startswith("<"): return None - return re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name)[:_DATABASE_NAME_STEM_MAX_LENGTH] or None + return re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name) or None @staticmethod def _caller_name() -> Optional[str]: @@ -237,9 +234,8 @@ def setup_database(self, document_store: DocumentStore) -> None: def wait_for_indexing( store: DocumentStore, database: Optional[str] = None, - timeout: Optional[timedelta] = None, + timeout: timedelta = timedelta(seconds=60), ) -> None: - timeout = timeout if timeout is not None else _DEFAULT_WAIT_FOR_INDEXING_TIMEOUT admin = store.maintenance.for_database(database) start_time = time.monotonic() diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py index 3a38347..381c715 100644 --- a/tests/test_database_naming.py +++ b/tests/test_database_naming.py @@ -36,9 +36,6 @@ def test_synthetic_frame_names_fall_back_to_test(self): def test_illegal_characters_are_replaced(self): self.assertEqual("weird_name_with_chars", RavenTestDriver._database_stem("weird name/with:chars")) - def test_a_long_name_is_truncated(self): - self.assertEqual(100, len(RavenTestDriver._database_stem("x" * 300))) - def test_synthetic_names_have_no_stem(self): self.assertIsNone(RavenTestDriver._database_stem("")) From b1f1e631c15555fae7a27e248fe6665504320c73 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 16:37:20 +0200 Subject: [PATCH 29/32] RavenDB-27141 Name test databases after the calling test by default --- README.md | 2 +- labs/02_embedded_per_test.py | 2 +- ravendb_test_driver/raven_test_driver.py | 2 +- tests/test_database_naming.py | 26 ++++++++++++++---------- tests/test_end_to_end.py | 2 -- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index fbc53c1..b7f40c4 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort | Switch | Default | What it does | |--------|---------|--------------| | `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | -| `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | +| `RavenTestDriver.use_caller_name_for_database` | `True` | Names databases after the calling test (`test_stores_a_person_3`). Set `False` for plain `test_3` | | `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | | `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | diff --git a/labs/02_embedded_per_test.py b/labs/02_embedded_per_test.py index 96ddab9..b663953 100644 --- a/labs/02_embedded_per_test.py +++ b/labs/02_embedded_per_test.py @@ -28,7 +28,7 @@ def main() -> None: # still open and deletes those databases, so a test that throws halfway still cleans up. with RavenTestDriver() as driver: forgotten = driver.get_document_store() # no forgotten.close() anywhere - assert forgotten.database.startswith("test_"), forgotten.database + assert forgotten.database.startswith("main_"), forgotten.database # named after the caller assert driver.disposed diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 869e493..139b968 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -43,7 +43,7 @@ class RavenTestDriver: - use_caller_name_for_database: bool = False + use_caller_name_for_database: bool = True _TEST_SERVER: EmbeddedServer = EmbeddedServer() _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver._run_server()) diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py index 381c715..973f382 100644 --- a/tests/test_database_naming.py +++ b/tests/test_database_naming.py @@ -7,27 +7,31 @@ from tests.support import isolate_environment -class _CallerNameDriver(RavenTestDriver): - use_caller_name_for_database = True +class _PlainNameDriver(RavenTestDriver): + use_caller_name_for_database = False -class TestCallerNameOptIn(TestCase): - def test_is_off_by_default(self): - self.assertFalse(RavenTestDriver.use_caller_name_for_database) - self.assertTrue(RavenTestDriver()._next_database_name(None).startswith("test_")) +class TestCallerName(TestCase): + def test_is_on_by_default(self): + self.assertTrue(RavenTestDriver.use_caller_name_for_database) + + def test_uses_the_calling_test_name(self): + name = RavenTestDriver()._next_database_name(None) - def test_uses_the_calling_test_name_when_switched_on(self): - name = _CallerNameDriver()._next_database_name(None) + self.assertTrue(name.startswith("test_uses_the_calling_test_name_"), name) - self.assertTrue(name.startswith("test_uses_the_calling_test_name_when_switched_on_"), name) + def test_can_be_switched_off(self): + name = _PlainNameDriver()._next_database_name(None) + + self.assertTrue(name.startswith("test_"), name) def test_an_explicit_database_still_wins(self): - name = _CallerNameDriver()._next_database_name("chosen") + name = RavenTestDriver()._next_database_name("chosen") self.assertTrue(name.startswith("chosen_"), name) def test_synthetic_frame_names_fall_back_to_test(self): - driver = _CallerNameDriver() + driver = RavenTestDriver() name = (lambda: driver._next_database_name(None))() # co_name is here, which is not a usable database name. diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index e45daee..593f65c 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -67,11 +67,9 @@ def test_closing_the_driver_deletes_databases_of_stores_left_open(self): @skip_without_own_server class TestDatabaseNamingAgainstServer(TestCase): def setUp(self): - self.addCleanup(setattr, RavenTestDriver, "use_caller_name_for_database", False) isolate_environment(self) def test_caller_name_and_process_id_reach_the_created_database(self): - RavenTestDriver.use_caller_name_for_database = True os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" with RavenTestDriver() as driver: From c9b1e570934608d2489f59d9e80b656a69a22c74 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 16:42:04 +0200 Subject: [PATCH 30/32] RavenDB-27141 Keep caller-name database naming opt-in Making it the default gave no easy way out: opting out means subclassing RavenTestDriver or mutating a class attribute globally, and neither is something a suite should have to do to keep the names it already has. --- README.md | 2 +- labs/02_embedded_per_test.py | 2 +- ravendb_test_driver/raven_test_driver.py | 2 +- tests/test_database_naming.py | 26 ++++++++++-------------- tests/test_end_to_end.py | 2 ++ 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b7f40c4..fbc53c1 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Defaults are chosen so an existing suite keeps working. These are the knobs wort | Switch | Default | What it does | |--------|---------|--------------| | `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | -| `RavenTestDriver.use_caller_name_for_database` | `True` | Names databases after the calling test (`test_stores_a_person_3`). Set `False` for plain `test_3` | +| `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | | `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | | `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | diff --git a/labs/02_embedded_per_test.py b/labs/02_embedded_per_test.py index b663953..96ddab9 100644 --- a/labs/02_embedded_per_test.py +++ b/labs/02_embedded_per_test.py @@ -28,7 +28,7 @@ def main() -> None: # still open and deletes those databases, so a test that throws halfway still cleans up. with RavenTestDriver() as driver: forgotten = driver.get_document_store() # no forgotten.close() anywhere - assert forgotten.database.startswith("main_"), forgotten.database # named after the caller + assert forgotten.database.startswith("test_"), forgotten.database assert driver.disposed diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 139b968..869e493 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -43,7 +43,7 @@ class RavenTestDriver: - use_caller_name_for_database: bool = True + use_caller_name_for_database: bool = False _TEST_SERVER: EmbeddedServer = EmbeddedServer() _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver._run_server()) diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py index 973f382..381c715 100644 --- a/tests/test_database_naming.py +++ b/tests/test_database_naming.py @@ -7,31 +7,27 @@ from tests.support import isolate_environment -class _PlainNameDriver(RavenTestDriver): - use_caller_name_for_database = False +class _CallerNameDriver(RavenTestDriver): + use_caller_name_for_database = True -class TestCallerName(TestCase): - def test_is_on_by_default(self): - self.assertTrue(RavenTestDriver.use_caller_name_for_database) - - def test_uses_the_calling_test_name(self): - name = RavenTestDriver()._next_database_name(None) - - self.assertTrue(name.startswith("test_uses_the_calling_test_name_"), name) +class TestCallerNameOptIn(TestCase): + def test_is_off_by_default(self): + self.assertFalse(RavenTestDriver.use_caller_name_for_database) + self.assertTrue(RavenTestDriver()._next_database_name(None).startswith("test_")) - def test_can_be_switched_off(self): - name = _PlainNameDriver()._next_database_name(None) + def test_uses_the_calling_test_name_when_switched_on(self): + name = _CallerNameDriver()._next_database_name(None) - self.assertTrue(name.startswith("test_"), name) + self.assertTrue(name.startswith("test_uses_the_calling_test_name_when_switched_on_"), name) def test_an_explicit_database_still_wins(self): - name = RavenTestDriver()._next_database_name("chosen") + name = _CallerNameDriver()._next_database_name("chosen") self.assertTrue(name.startswith("chosen_"), name) def test_synthetic_frame_names_fall_back_to_test(self): - driver = RavenTestDriver() + driver = _CallerNameDriver() name = (lambda: driver._next_database_name(None))() # co_name is here, which is not a usable database name. diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 593f65c..e45daee 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -67,9 +67,11 @@ def test_closing_the_driver_deletes_databases_of_stores_left_open(self): @skip_without_own_server class TestDatabaseNamingAgainstServer(TestCase): def setUp(self): + self.addCleanup(setattr, RavenTestDriver, "use_caller_name_for_database", False) isolate_environment(self) def test_caller_name_and_process_id_reach_the_created_database(self): + RavenTestDriver.use_caller_name_for_database = True os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" with RavenTestDriver() as driver: From 0bb30236e28cf62df48c3134f98ed824221c516f Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 17:15:07 +0200 Subject: [PATCH 31/32] RavenDB-27141 Move the database counter next to its only user and give it a real name --- ravendb_test_driver/raven_test_driver.py | 21 +++++++++++---------- tests/test_driver_lifecycle.py | 10 +++++----- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 869e493..2d637fb 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -47,8 +47,6 @@ class RavenTestDriver: _TEST_SERVER: EmbeddedServer = EmbeddedServer() _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver._run_server()) - _INDEX = 0 - _INDEX_LOCK = threading.Lock() _GLOBAL_SERVER_OPTIONS: Optional[ServerOptions] = None _EMPTY_SETTINGS_FILE_NAME: Optional[str] = None _EXTERNAL_SERVER_URL: Optional[str] = None @@ -66,13 +64,6 @@ def __enter__(self) -> "RavenTestDriver": def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() - @staticmethod - def _next_index() -> int: - # Qualified, not cls: 'cls._INDEX += 1' would shadow the counter per subclass. - with RavenTestDriver._INDEX_LOCK: - RavenTestDriver._INDEX += 1 - return RavenTestDriver._INDEX - @staticmethod def _remove_empty_settings_file(path: str) -> None: try: @@ -207,6 +198,16 @@ def _environment_flag(name: str, default: bool = False) -> bool: return default return value not in _FALSY_ENVIRONMENT_VALUES + _DATABASE_COUNTER = 0 + _DATABASE_COUNTER_LOCK = threading.Lock() + + @staticmethod + def _next_database_number() -> int: + # Qualified, not cls: 'cls._DATABASE_COUNTER += 1' would shadow it per subclass. + with RavenTestDriver._DATABASE_COUNTER_LOCK: + RavenTestDriver._DATABASE_COUNTER += 1 + return RavenTestDriver._DATABASE_COUNTER + @classmethod def _next_database_name(cls, database: Optional[str] = None) -> str: stem = database @@ -217,7 +218,7 @@ def _next_database_name(cls, database: Optional[str] = None) -> str: if cls._environment_flag(_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE): # The counter restarts per process, so runners sharing a server would collide. parts.append(str(os.getpid())) - parts.append(str(cls._next_index())) + parts.append(str(cls._next_database_number())) return "_".join(parts) diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py index d95ec05..d0c38b4 100644 --- a/tests/test_driver_lifecycle.py +++ b/tests/test_driver_lifecycle.py @@ -72,21 +72,21 @@ def explode(_): class TestDatabaseNameAllocation(TestCase): def test_two_drivers_get_distinct_database_names(self): - # self._INDEX += 1 read the class attribute and wrote an instance one, so every + # self._DATABASE_COUNTER += 1 read the class attribute and wrote an instance one, so every # driver restarted numbering at 1 and two live drivers both asked for test_1. with RavenTestDriver() as first, RavenTestDriver() as second: with first.get_document_store() as first_store: with second.get_document_store() as second_store: self.assertNotEqual(first_store.database, second_store.database) - def test_index_stays_a_class_attribute(self): + def test_counter_stays_a_class_attribute(self): driver = RavenTestDriver() - before = RavenTestDriver._INDEX + before = RavenTestDriver._DATABASE_COUNTER with driver.get_document_store(): pass - self.assertEqual(before + 1, RavenTestDriver._INDEX) - self.assertNotIn("_INDEX", driver.__dict__) + self.assertEqual(before + 1, RavenTestDriver._DATABASE_COUNTER) + self.assertNotIn("_DATABASE_COUNTER", driver.__dict__) driver.close() From f01f6050c0afb1029741d5e2988b729865e3004f Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 27 Aug 2026 17:16:25 +0200 Subject: [PATCH 32/32] RavenDB-27141 Trim a comment and a docstring to what they need to say --- ravendb_test_driver/raven_test_driver.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index 2d637fb..f12bcb2 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -203,7 +203,6 @@ def _environment_flag(name: str, default: bool = False) -> bool: @staticmethod def _next_database_number() -> int: - # Qualified, not cls: 'cls._DATABASE_COUNTER += 1' would shadow it per subclass. with RavenTestDriver._DATABASE_COUNTER_LOCK: RavenTestDriver._DATABASE_COUNTER += 1 return RavenTestDriver._DATABASE_COUNTER @@ -279,9 +278,7 @@ def wait_for_user_to_continue_the_test( ) -> None: """Open Studio and block until a 'Debug/Done' document shows up in this database. - Waits as long as it takes, because a human is looking at Studio. Pass a `timeout` to - bound it, and set RAVENDB_TEST_WAIT_FOR_USER to 0/false/no/off to skip the wait - entirely, which is how a CI job protects itself from a call left in committed code. + Unbounded unless `timeout` is given. RAVENDB_TEST_WAIT_FOR_USER=0/false/no/off skips it. """ if not self._environment_flag(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE, default=True): return