diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b8a5b00
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1d8a1d2..58341d9 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,30 +9,27 @@ on:
jobs:
test:
+ # Path A: embedded framework-dependent server -> needs a system .NET. The 7.2 server
+ # targets net10.0 (7.1 was net8.0), so we install .NET 10 explicitly and assert the
+ # bundled server really needs it (no more green-by-luck on a preinstalled runtime).
runs-on: ${{ matrix.os }}
- env:
- # .NET 8 (the server's target) is exercised by default resolution; .NET 10 must be
- # forced since runners preinstall .NET 8 and would otherwise win. tests/pin_framework_version.
- RAVENDB_TEST_FRAMEWORK_VERSION: ${{ matrix.dotnet == '10.0' && '10.0.x' || '' }}
strategy:
fail-fast: false
- # Latest Python only (the ravendb client requires 3.10+); full OS x .NET matrix.
matrix:
os: [ubuntu-latest, windows-latest]
- dotnet: ["8.0", "10.0"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Python 3.13
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"
- - name: Set up .NET ${{ matrix.dotnet }}
- uses: actions/setup-dotnet@v4
+ - name: Set up .NET 10
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
- dotnet-version: ${{ matrix.dotnet }}
+ dotnet-version: "10.0"
- name: Install package
run: |
@@ -44,12 +41,57 @@ jobs:
pip install black
black --check .
- # Populates ravendb_embedded/target/nuget via the project's sdist hook (the server the
- # tests copy from). setuptools is installed explicitly; modern virtualenvs omit it.
+ # Downloads + unpacks the bundled (framework-dependent) server into
+ # ravendb_embedded/target/nuget. setuptools is installed explicitly; modern venvs omit it.
- name: Fetch bundled RavenDB server
run: |
pip install setuptools wheel
python setup.py sdist
+ - name: Assert CI .NET matches the server's required runtime
+ run: python scripts/check_dotnet_requirement.py
+
- name: Run tests
run: python -m unittest discover -s tests
+
+ - name: Run Lab 01 (embedded zero-config)
+ run: python labs/01_embedded_zero_config.py
+
+ - name: Run Lab 04 (secured embedded)
+ run: python labs/04_embedded_secured.py
+
+ - name: Run Lab 05 (persistent data directory)
+ run: python labs/05_embedded_persistent.py
+
+ self-contained:
+ # Path B: external self-contained server -> runs the native apphost, no system .NET.
+ # We do not set up .NET; the lab uses a bogus dot_net_path so a successful run proves the
+ # apphost path is genuinely dotnet-free even though the runner happens to ship a .NET.
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+
+ - name: Set up Python 3.13
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.13"
+
+ - name: Install package
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .
+
+ - name: Download a self-contained RavenDB server
+ run: |
+ curl -fsSL -o ravendb.tar.bz2 "https://hibernatingrhinos.com/downloads/RavenDB%20for%20Linux%20x64/latest?version=7.2"
+ mkdir server
+ tar xjf ravendb.tar.bz2 -C server
+
+ - name: Run Lab 02 (self-contained, no system .NET)
+ run: |
+ SERVER="$(find server -type d -name Server | head -1)"
+ RAVENDB_SELF_CONTAINED_SERVER="$SERVER" python labs/02_embedded_external_server.py
+
+ - name: Run Lab 03 (on-demand cached self-contained, no system .NET)
+ run: python labs/03_on_demand_server.py
diff --git a/.gitignore b/.gitignore
index 3474397..5f6470d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
/dist
/ravendb_embedded.egg-info
/ravendb_embedded/target
+/ravendb_embedded/RavenDB
*.pyc
*.log
*.raven-topology
diff --git a/MANIFEST.in b/MANIFEST.in
index aaf6e1b..6460748 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,3 @@
graft ravendb_embedded/target/nuget
-include README.rst
+include README.md
recursive-exclude tests *
\ No newline at end of file
diff --git a/README.md b/README.md
index 9f70d56..6e0477e 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,157 @@
+# ravendb-embedded
-## Overview
-ravendb-embedded is a RavenDB package for running RavenDB in embedded mode.
+`ravendb-embedded` runs a real RavenDB server from inside your Python program. You `pip install`
+it, start the server in-process, and talk to it with the normal `ravendb` client. There is no
+separate server to install, configure, or keep running: the server's lifetime follows your
+process.
+
+Reach for it when you want:
+
+- **Local development** without setting up a standalone RavenDB.
+- **Integration tests** against a real server instead of a mock (see also `ravendb-test-driver`).
+- **Small or self-contained apps** that ship the database alongside the code.
```python
from ravendb_embedded import EmbeddedServer
-EmbeddedServer().start_server()
-with EmbeddedServer().get_document_store("Embedded") as store:
- with store.open_session() as session:
- session.store(User(name="Ilay", age=4))
- session.save_changes()
-```
+with EmbeddedServer() as server:
+ server.start_server()
+ with server.get_document_store("Embedded") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+```
## Installation
-Install from [PyPi](https://pypi.python.org/pypi), as [ravendb-embedded](https://pypi.python.org/project/ravendb-embedded).
+
```bash
pip install ravendb-embedded
```
-Install ravendb-embedded from pip will provide you with a copy of RavenDB server binaries files as well.
+
+The install includes a copy of the RavenDB server binaries. Python 3.10+ is required.
+
+## The .NET requirement (read this)
+
+The bundled server is a .NET application, so a matching **.NET runtime** must be on the machine.
+The required version tracks the bundled server:
+
+| `ravendb-embedded` version | Required runtime |
+|----------------------------|------------------|
+| 7.2.x | .NET 10 |
+| 7.1.x | .NET 8 |
+
+Check what is installed with `dotnet --list-runtimes` (look for `Microsoft.NETCore.App`). Because
+the requirement follows the bundled server, it can change on a minor upgrade, so re-check it when
+you bump versions.
+
+If the machine cannot or should not have .NET, use the self-contained path under
+[Run without installing .NET](#run-without-installing-net) below.
## Usage
-#### Start a server
-To start RavenDB server, call `start_server()` method from `EmbeddedServer` instance.
-```python
-from ravendb_embedded import EmbeddedServer
-ravendb_server = EmbeddedServer()
-ravendb_server.start_server()
-```
-To be more in control about your server `start_server` method can take `server_options`.
+The three sections below are the ways people actually use this package. Pick the one that matches
+your environment; each links to a runnable walkthrough in `labs/`.
+
+### Run it (the default, needs .NET)
+Start the server and get a document store. This is the zero-config path and uses the system .NET
+described above. Pass a `ServerOptions` when you want to control where data lives, the bind URL,
+and so on.
-#### ServerOptions
-* **framework_version** - The framework version to run the server with.
-* **data_directory** - Where to save the database data (if None the files will be saved in RavenDB folder in the base folder).
-* **server_url** - The url the server will be opened if None the server will open on local host.
-* **dotnet_path** - Where dotnet.exe is located if dotnet in the PATH nothing needed here (If .net core is not installed in your machine
-you can download [dotnet binaries](https://www.microsoft.com/net/download/windows) and just put the path to it)
-* **command_line_args** - A list of all [server command args](https://ravendb.net/docs/article-page/6.0/csharp/server/configuration/command-line-arguments).
```python
from ravendb_embedded import EmbeddedServer, ServerOptions
-server_options = ServerOptions(data_directory="MYPATH/RavenDBDataDir")
-EmbeddedServer().start_server(server_options)
+options = ServerOptions()
+options.data_directory = "MYPATH/RavenDBDataDir" # optional; defaults to a local RavenDB folder
+
+with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("MyDb") as store:
+ ... # ordinary ravendb client code
```
----
-##### Security
-There are options to make ravendb secured in ravendb-embedded:
-
-`secured(server_pfx_certificate_path, client_pem_certificate_path, server_pfx_certificate_password=None, ca_certificate_path = None)`
-- For this option you will put path to a .pfx and .pem files and a password/ca cert if you have one.
-- Server certificate password and CA cert file are optional arguments. Minimal setup requires both .pfx server and .pem client certificates.
- ```python
- from ravendb_embedded import EmbeddedServer, ServerOptions
-
- server_options = ServerOptions()
- server_options.secured("PATH_TO_SERVER_PFX_CERT_FILE", "PATH_TO_CLIENT_PEM_CERT")
- EmbeddedServer.start_server(server_options)
- ```
----
-#### Get Document Store
-After initialize and start the server we can use `get_document_store` method to be able to get a DocumentStore
-and start work with RavenDB as normal.
+
+Runnable walkthrough: [`labs/01-embedded-zero-config.md`](labs/01-embedded-zero-config.md).
+
+### Run without installing .NET
+
+On locked-down hosts or minimal CI images where you do not want a system .NET, bring a
+**self-contained** RavenDB build (it bundles its own runtime). Point the server at the extracted
+`Server` folder: the driver detects the bundled runtime and launches the server's native apphost
+directly, never calling `dotnet`.
```python
-from ravendb_embedded import EmbeddedServer
+from ravendb_embedded import EmbeddedServer, ServerOptions
-ravendb_server = EmbeddedServer()
-ravendb_server.start_server()
+options = ServerOptions()
+options.with_external_server("/path/to/extracted/Server") # a self-contained build
-with ravendb_server.get_document_store("Test") as store:
-# Your code here
+with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("MyDb") as store:
+ ...
```
----
-##### DatabaseOptions
-* **database_name** - The name of the database
-* **skip_creating_database** - `get_document_store` will create a new database if the database is not exists,
-if this option if True we won't create the database (Default False).
+Download the Server package for your platform from the [RavenDB downloads page](https://ravendb.net/downloads);
+the server files live in the archive's `Server/` folder. Runnable walkthrough:
+[`labs/02-embedded-external-server.md`](labs/02-embedded-external-server.md).
+
+Or skip the manual download and let the driver fetch and cache one for you on first use:
```python
-# In this example we won't create the Test database if not exists will raise an exception
-from ravendb_embedded import EmbeddedServer, DatabaseOptions
+options = ServerOptions()
+options.with_auto_downloaded_server() # downloads + caches a self-contained server, no .NET needed
+```
-ravendb_server = EmbeddedServer()
-ravendb_server.start_server()
+Walkthrough: [`labs/03-on-demand-server.md`](labs/03-on-demand-server.md).
-database_options = DatabaseOptions.from_database_name("Test")
-database_options.skip_creating_database = True
+### Don't manage a server at all (tests)
-with ravendb_server.get_document_store_from_options(database_options) as store:
-# Your code here
-```
+For test suites that should not touch .NET or embedded startup, `ravendb-test-driver` can attach
+to a RavenDB you run yourself (Docker, testcontainers, a shared CI service) while still giving
+each test its own database. See the
+[`ravendb-python-testdriver`](https://github.com/ravendb/ravendb-python-testdriver) repository.
-#### Open RavenDB studio in the browser
-To open RavenDB studio from ravendb-embedded you can use `open_studio_in_browser` method and the studio will open automatically
-one your default browser.
+## Configuration
-```python
-from ravendb_embedded import EmbeddedServer
-ravendb_server = EmbeddedServer()
-ravendb_server.start_server()
+### `ServerOptions`
+
+Create `ServerOptions()` and set attributes:
+
+- `data_directory`: where database data is stored (defaults to a local `RavenDB` folder). Set a
+ stable path for data that outlives the process, see
+ [`labs/05-embedded-persistent.md`](labs/05-embedded-persistent.md).
+- `server_url`: the URL to bind (defaults to localhost on a free port).
+- `dot_net_path`: path to `dotnet` when it is not on `PATH` (ignored on the self-contained path).
+- `command_line_args`: extra [server command-line arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments).
+- `framework_version`: pin an exact .NET version (advanced; leave empty to autodetect the installed runtime).
-ravendb_server.open_studio_in_browser()
+### Security
+
+Secure the server with `ServerOptions.secured()`:
+
+```python
+options = ServerOptions()
+options.secured(
+ server_pfx_certificate_path, # server certificate (.pfx), required
+ client_pem_certificate_path, # client certificate (.pem)
+ server_pfx_certificate_password=None,
+ ca_certificate_path=None,
+)
```
+
+Runnable example (HTTPS + client-certificate auth):
+[`labs/04-embedded-secured.md`](labs/04-embedded-secured.md).
+
+### Working with data
+
+`get_document_store(database_name)` returns a `DocumentStore` you use like any RavenDB client. For
+finer control, build a `DatabaseOptions` (via `DatabaseOptions.from_database_name`) and call
+`get_document_store_from_options`; set `skip_creating_database=True` to not auto-create the
+database.
+
+Call `open_studio_in_browser()` to open RavenDB Studio in your default browser.
+
+## Labs
+
+The `labs/` folder holds runnable, self-checking guides, one per usage case above. Start at
+[`labs/README.md`](labs/README.md).
diff --git a/README.rst b/README.rst
deleted file mode 100644
index e706ad5..0000000
--- a/README.rst
+++ /dev/null
@@ -1,84 +0,0 @@
-========
-Overview
-========
-
-``ravendb-embedded`` is a RavenDB package for running RavenDB in embedded mode.
-
-.. code-block:: python
-
- EmbeddedServer().start_server()
- with EmbeddedServer().get_document_store("Embedded") as store:
- with store.open_session() as session:
- session.store(User(name="Ilay", age=4))
- session.save_changes()
-
-============
-Installation
-============
-
-Install ``ravendb-embedded`` from `PyPi `_ using:
-
-.. code-block:: bash
-
- pip install ravendb-embedded
-
-Installing ``ravendb-embedded`` from pip will also provide you with a copy of the RavenDB server binary files.
-
-========
-Usage
-========
-
-Start a server
---------------
-
-To start the RavenDB server, call the ``start_server()`` method from an ``EmbeddedServer`` instance.
-
-.. code-block:: python
-
- EmbeddedServer.start_server()
-
-For more control over your server, you can pass ``server_options`` to the ``start_server()`` method.
-
-ServerOptions
--------------
-
-- ``framework_version``: The framework version to run the server with.
-- ``data_directory``: Where to save the database data (if None, the files will be saved in the RavenDB folder in the base folder).
-- ``server_url``: The URL the server will be opened on (if None, the server will open on localhost).
-- ``dotnet_path``: The location of ``dotnet.exe`` (if .NET Core is not installed on your machine, you can download `dotnet binaries `_ and provide the path).
-- ``command_line_args``: A list of all `server command arguments `_.
-
-.. code-block:: python
-
- server_options = ServerOptions(data_directory="MYPATH/RavenDBDataDir")
- EmbeddedServer().start_server(server_options)
-
-Security
---------
-
-You can secure ``ravendb-embedded`` using the ``secured()`` method:
-
-.. code-block:: python
-
- secured(server_pfx_certificate_path, client_pem_certificate_path, server_pfx_certificate_password=None, ca_certificate_path=None)
-
-- Provide the path to ``.pfx`` and ``.pem`` files, and optionally a password and CA certificate file.
-- Minimal setup requires both a ``.pfx`` server and a ``.pem`` client certificate.
-
-Get Document Store
-------------------
-
-After initializing and starting the server, you can use the ``get_document_store`` method to obtain a ``DocumentStore`` and start working with RavenDB as usual.
-
-``get_document_store`` method can take either just the ``database_name`` or ``DatabaseOptions``.
-
-DatabaseOptions
----------------
-
-- ``database_name``: The name of the database.
-- ``skip_creating_database``: ``get_document_store`` will create a new database if it does not exist. If this option is set to True, the database won't be created (Default False).
-
-Open RavenDB Studio in the Browser
------------------------------------
-
-To open RavenDB Studio from ``ravendb-embedded``, use the ``open_studio_in_browser`` method, and the studio will open automatically in your default browser.
diff --git a/labs/01-embedded-zero-config.md b/labs/01-embedded-zero-config.md
new file mode 100644
index 0000000..d07ca6c
--- /dev/null
+++ b/labs/01-embedded-zero-config.md
@@ -0,0 +1,47 @@
+# Lab 01: Embedded, zero-config
+
+**For:** local development or CI on a machine that already has .NET installed. You want
+`pip install ravendb-embedded` and a working RavenDB server with nothing set up on the side.
+
+**Requirement:** RavenDB 7.2.x needs **.NET 10** (7.1.x needed .NET 8). Verify with
+`dotnet --list-runtimes` and look for `Microsoft.NETCore.App 10.0.x`.
+
+## Run it
+
+```bash
+pip install ravendb-embedded
+python labs/01_embedded_zero_config.py
+```
+
+The complete, runnable example is [`01_embedded_zero_config.py`](01_embedded_zero_config.py).
+The core is just:
+
+```python
+from ravendb_embedded import EmbeddedServer
+
+with EmbeddedServer() as server:
+ server.start_server()
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+```
+
+## How the .NET runtime is picked (under the hood)
+
+1. The default provider (`CopyServerFromNugetProvider`) unpacks the **framework-dependent**
+ server (`Raven.Server.dll`) that ships inside the wheel.
+2. `RavenServerRunner` launches it as **`dotnet Raven.Server.dll ...`**, using the `dotnet`
+ found on your `PATH` (`ServerOptions.dot_net_path`).
+3. `ServerOptions.framework_version` is empty by default, so no `--fx-version` is passed and
+ standard .NET host resolution applies. The host does **not** roll forward across major
+ versions, which is why a net10.0 server hard-fails on a machine that only has .NET 8
+ (error: "You must install ... version 10.0.x").
+4. The one exception: point the server at a **self-contained** build (an apphost
+ `Raven.Server` with the runtime bundled) and it runs **without `dotnet`**. That is Lab 02.
+
+## Takeaway
+
+This is the most convenient path, but it buys a hard dependency on a system-wide .NET in a
+specific major version. If you would rather not manage .NET, use Lab 02 (external
+self-contained server) or Lab 03 (attach to a server you run yourself, e.g. via Docker).
diff --git a/labs/01_embedded_zero_config.py b/labs/01_embedded_zero_config.py
new file mode 100644
index 0000000..3526c97
--- /dev/null
+++ b/labs/01_embedded_zero_config.py
@@ -0,0 +1,39 @@
+"""Lab 01: Embedded, zero-config.
+
+For: local development or CI on a machine that already has .NET installed.
+You want `pip install ravendb-embedded` and a working RavenDB server, no extra setup.
+
+Requirement: RavenDB 7.2.x needs .NET 10 (7.1.x needed .NET 8).
+Check your runtime with: dotnet --list-runtimes (look for Microsoft.NETCore.App 10.0.x)
+
+Run: python labs/01_embedded_zero_config.py
+"""
+
+import tempfile
+from pathlib import Path
+
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+
+def main() -> None:
+ with tempfile.TemporaryDirectory() as work_dir:
+ options = ServerOptions()
+ options.data_directory = str(Path(work_dir, "RavenDB"))
+ options.logs_path = str(Path(work_dir, "Logs"))
+
+ with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+
+ with store.open_session() as session:
+ loaded = session.load("people/1", dict)
+ assert loaded["name"] == "Ayende", loaded
+
+ print("Lab 01 OK: embedded server started, wrote and read a document.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/02-embedded-external-server.md b/labs/02-embedded-external-server.md
new file mode 100644
index 0000000..184796a
--- /dev/null
+++ b/labs/02-embedded-external-server.md
@@ -0,0 +1,45 @@
+# Lab 02: External self-contained server (no system .NET)
+
+**For:** machines or CI that do not have .NET installed. You bring a **self-contained**
+RavenDB server (it bundles the .NET runtime); the driver runs its native apphost directly and
+never calls `dotnet`.
+
+## Get a self-contained server
+
+Download the Server package for your platform from the RavenDB downloads page:
+. You get an archive; extract it, and the server files are in its
+`Server/` subfolder.
+
+## Run it
+
+```bash
+pip install ravendb-embedded
+RAVENDB_SELF_CONTAINED_SERVER=/path/to/extracted/Server python labs/02_embedded_external_server.py
+```
+
+The complete example is [`02_embedded_external_server.py`](02_embedded_external_server.py).
+The core is:
+
+```python
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+options = ServerOptions()
+options.with_external_server("/path/to/extracted/Server") # a self-contained build
+with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("Lab") as store:
+ ... # ordinary RavenDB client code, with no .NET on the machine
+```
+
+## How it decides not to use `dotnet`
+
+`ExternalServerProvider` reads `Raven.Server.runtimeconfig.json`. A self-contained build lists
+`includedFrameworks` (its bundled runtime), so the driver marks it "run the native apphost"
+and launches `Raven.Server` (or `Raven.Server.exe` on Windows) directly, with **no `dotnet`**.
+A framework-dependent build (only a `framework` reference, no bundled runtime) still runs via
+`dotnet Raven.Server.dll` and needs a system .NET, which is Lab 01.
+
+## Takeaway
+
+No .NET on the box, at the cost of fetching and caching the server build yourself. If you would
+rather not manage a server at all, run one in a container and attach to it: Lab 03.
diff --git a/labs/02_embedded_external_server.py b/labs/02_embedded_external_server.py
new file mode 100644
index 0000000..a004c02
--- /dev/null
+++ b/labs/02_embedded_external_server.py
@@ -0,0 +1,49 @@
+"""Lab 02: External self-contained server (no system .NET).
+
+For: machines or CI that do NOT have .NET installed. You bring a self-contained RavenDB
+server build (it bundles the .NET runtime), and the driver runs its native apphost directly,
+never calling `dotnet`.
+
+Get the Server package for your platform from the RavenDB downloads page
+(https://ravendb.net/downloads), extract it; the server files live in the `Server/` subfolder.
+
+Run:
+ RAVENDB_SELF_CONTAINED_SERVER=/path/to/extracted/Server python labs/02_embedded_external_server.py
+"""
+
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+SERVER = os.environ.get("RAVENDB_SELF_CONTAINED_SERVER") or (sys.argv[1] if len(sys.argv) > 1 else None)
+if not SERVER:
+ sys.exit("Set RAVENDB_SELF_CONTAINED_SERVER (or pass a path) to the extracted 'Server' folder. See the header.")
+
+
+def main() -> None:
+ with tempfile.TemporaryDirectory() as work:
+ options = ServerOptions()
+ options.data_directory = str(Path(work, "data"))
+ options.logs_path = str(Path(work, "logs"))
+ # Bogus on purpose: a self-contained build must never call `dotnet`, so if it still boots the no-.NET path works.
+ options.dot_net_path = "__no_dotnet__"
+ options.with_external_server(SERVER)
+
+ with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+
+ with store.open_session() as session:
+ assert session.load("people/1", dict)["name"] == "Ayende"
+
+ print("Lab 02 OK: self-contained server ran with NO system .NET.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/03-on-demand-server.md b/labs/03-on-demand-server.md
new file mode 100644
index 0000000..6054476
--- /dev/null
+++ b/labs/03-on-demand-server.md
@@ -0,0 +1,51 @@
+# Lab 03: On-demand, cached self-contained server (no .NET)
+
+**For:** the no-.NET experience of Lab 02 without downloading and extracting a server yourself.
+Call `with_auto_downloaded_server()`; on first use the driver fetches a self-contained build for this
+platform, caches it, and reuses the cache from then on. A self-contained build bundles its own
+runtime, so the server runs its native apphost and never calls `dotnet`.
+
+## Run it
+
+```bash
+pip install ravendb-embedded
+python labs/03_on_demand_server.py
+```
+
+The complete example is [`03_on_demand_server.py`](03_on_demand_server.py). The core is:
+
+```python
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+options = ServerOptions()
+options.with_auto_downloaded_server() # download + cache a self-contained server on first use
+
+with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("Lab") as store:
+ ... # ordinary RavenDB client code, with no .NET on the machine
+```
+
+`with_auto_downloaded_server(version=None, cache_root=None)` defaults the version to the installed
+package's RavenDB line and caches under `~/.cache/ravendb-embedded`. Pass `cache_root` to point
+it at a directory your CI restores between runs.
+
+## Why pulling `latest` is fine (on purpose)
+
+Fetching the `latest` self-contained build for the version line is deliberate. A self-contained
+build bundles its own .NET runtime, so it does not have to match anything on the host: whatever
+`latest` returns is a server that just runs. There is no host .NET compatibility matrix to pin
+against, which is exactly what makes "grab latest and run" safe here (a framework-dependent build
+could not make that promise). The cache then freezes whatever you first pulled, so later runs stay
+stable without any extra pinning.
+
+## Cost to know about
+
+The first use downloads a self-contained build (100 MB+) and the cache keeps it on disk. Every
+run after that is offline and instant. If disk or first-run latency matters, prefer Lab 02 (you
+provide the server) or Lab 01 (bundled server, needs .NET).
+
+## Takeaway
+
+`with_auto_downloaded_server()` gives the no-.NET path of Lab 02 with zero manual steps: one call, then
+ordinary client code.
diff --git a/labs/03_on_demand_server.py b/labs/03_on_demand_server.py
new file mode 100644
index 0000000..a1811d4
--- /dev/null
+++ b/labs/03_on_demand_server.py
@@ -0,0 +1,37 @@
+"""Lab 03: On-demand, cached self-contained server (no manual download, no .NET).
+
+For: Lab 02 (no system .NET) without downloading and extracting a server yourself. Call
+`with_auto_downloaded_server()`; on first use the driver fetches a self-contained build for this
+platform, caches it, and reuses the cache next time. Because the build is self-contained it runs
+its native apphost and never calls `dotnet`.
+
+Run: python labs/03_on_demand_server.py
+"""
+
+import tempfile
+from pathlib import Path
+
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+
+def main() -> None:
+ with tempfile.TemporaryDirectory() as work:
+ options = ServerOptions()
+ options.with_auto_downloaded_server()
+ options.data_directory = str(Path(work, "data"))
+ options.logs_path = str(Path(work, "logs"))
+
+ with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+ with store.open_session() as session:
+ assert session.load("people/1", dict)["name"] == "Ayende"
+
+ print("Lab 03 OK: on-demand self-contained server, cached and run with no system .NET.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/04-embedded-secured.md b/labs/04-embedded-secured.md
new file mode 100644
index 0000000..b478edd
--- /dev/null
+++ b/labs/04-embedded-secured.md
@@ -0,0 +1,43 @@
+# Lab 04: Secured embedded server (HTTPS + client certificate)
+
+**For:** running the embedded server with TLS and client-certificate authentication instead of
+plain HTTP. You bring a server certificate (`.pfx`) and a client certificate (`.pem`);
+`ServerOptions.secured()` wires them in, and the driver hands you a store already configured with
+the client cert, so your session code does not change.
+
+## Run it
+
+```bash
+pip install ravendb-embedded
+python labs/04_embedded_secured.py
+```
+
+The complete example is [`04_embedded_secured.py`](04_embedded_secured.py). The core is:
+
+```python
+from ravendb_embedded import EmbeddedServer, ServerOptions
+from ravendb_embedded.options import DatabaseOptions
+
+options = ServerOptions()
+options.secured(server_pfx_path, client_pem_path, ca_certificate_path=ca_crt_path)
+
+with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store_from_options(DatabaseOptions.from_database_name("Lab")) as store:
+ # store.urls[0] is now https://...; the client certificate is already attached
+ ...
+```
+
+## Certificates
+
+`secured()` needs a server `.pfx` and a client `.pem`; a password and a CA certificate are
+optional. In production you bring your own (for example from RavenDB's setup wizard or your CA).
+The lab generates a throwaway self-signed pair so it can run unattended; that generator is demo
+code, not something to ship. A RavenDB server certificate needs the right extensions
+(digitalSignature key usage, serverAuth/clientAuth EKU, and SANs for how the server is reached),
+which the lab's generator sets.
+
+## Takeaway
+
+Securing the embedded server is one `secured()` call plus certificates. Everything after
+`get_document_store` is ordinary RavenDB client code, now over HTTPS with client-certificate auth.
diff --git a/labs/04_embedded_secured.py b/labs/04_embedded_secured.py
new file mode 100644
index 0000000..4e64295
--- /dev/null
+++ b/labs/04_embedded_secured.py
@@ -0,0 +1,113 @@
+"""Lab 04: Secured embedded server (HTTPS + client-certificate auth).
+
+For: running the embedded server with TLS and client-certificate authentication instead of plain
+HTTP. Point `ServerOptions.secured()` at a server certificate (.pfx) and a client certificate
+(.pem); the driver wires the client cert into the store, so your session code is unchanged.
+
+In production you bring your own certificates. This lab generates a throwaway self-signed pair so
+it can run unattended.
+
+Run: python labs/04_embedded_secured.py
+"""
+
+import datetime
+import ipaddress
+import shutil
+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_embedded import EmbeddedServer, ServerOptions
+from ravendb_embedded.options import DatabaseOptions
+
+
+def _demo_certificates(directory):
+ """Throwaway self-signed server.pfx + client.pem (demo only; bring your own in production).
+
+ Carries what RavenDB requires of a server certificate: digitalSignature key usage,
+ serverAuth/clientAuth EKU, and SANs for how the embedded server is reached (localhost,
+ 127.0.0.1). The same cert doubles as the trusted admin client certificate.
+ """
+ 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)
+ cert = (
+ 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())
+ )
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM)
+ key_pem = key.private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()
+ )
+ server_pfx, client_pem, ca_crt = (
+ Path(directory, "server.pfx"),
+ Path(directory, "client.pem"),
+ Path(directory, "ca.crt"),
+ )
+ server_pfx.write_bytes(
+ pkcs12.serialize_key_and_certificates(b"localhost", key, cert, None, serialization.NoEncryption())
+ )
+ client_pem.write_bytes(key_pem + cert_pem)
+ ca_crt.write_bytes(cert_pem)
+ return str(server_pfx), str(client_pem), str(ca_crt)
+
+
+def main() -> None:
+ work = tempfile.mkdtemp()
+ try:
+ server_pfx, client_pem, ca_crt = _demo_certificates(work)
+
+ options = ServerOptions()
+ options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt)
+ options.data_directory = str(Path(work, "RavenDB"))
+ options.logs_path = str(Path(work, "Logs"))
+
+ with EmbeddedServer() as server:
+ server.start_server(options)
+ with server.get_document_store_from_options(DatabaseOptions.from_database_name("Lab")) as store:
+ assert store.urls[0].startswith("https://"), store.urls
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+ with store.open_session() as session:
+ assert session.load("people/1", dict)["name"] == "Ayende"
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+ print("Lab 04 OK: secured embedded server over HTTPS with client-certificate auth.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/05-embedded-persistent.md b/labs/05-embedded-persistent.md
new file mode 100644
index 0000000..9a1f869
--- /dev/null
+++ b/labs/05-embedded-persistent.md
@@ -0,0 +1,48 @@
+# Lab 05: Persistent data directory (data survives restarts)
+
+**For:** using the embedded server as a durable local database rather than a throwaway. Point
+`data_directory` at a fixed folder and your databases and documents live there, still present the
+next time you start the server against the same folder.
+
+## Run it
+
+```bash
+pip install ravendb-embedded
+python labs/05_embedded_persistent.py
+```
+
+The complete example is [`05_embedded_persistent.py`](05_embedded_persistent.py). The core is:
+
+```python
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+def options(data_directory, logs_path):
+ o = ServerOptions()
+ o.data_directory = data_directory # a fixed folder you reuse across restarts
+ o.logs_path = logs_path
+ return o
+
+# First run: write, then shut down.
+with EmbeddedServer() as server:
+ server.start_server(options(data_directory, logs_path))
+ with server.get_document_store("Lab") as store:
+ ... # store a document
+
+# Later run against the SAME data_directory: the document is still there.
+with EmbeddedServer() as server:
+ server.start_server(options(data_directory, logs_path))
+ with server.get_document_store("Lab") as store:
+ ... # load it back
+```
+
+## Notes
+
+- The default `data_directory` is a folder under the package location, which is fine for tests
+ but not what you want for real data. Set it explicitly to a path you control.
+- `get_document_store("Lab")` reuses the existing database on the second run; it only creates one
+ when it is missing.
+
+## Takeaway
+
+Embedded is not in-memory. Give it a stable `data_directory` and it behaves like a normal local
+RavenDB whose data outlives the process.
diff --git a/labs/05_embedded_persistent.py b/labs/05_embedded_persistent.py
new file mode 100644
index 0000000..18de0d0
--- /dev/null
+++ b/labs/05_embedded_persistent.py
@@ -0,0 +1,51 @@
+"""Lab 05: Persistent data directory (data survives restarts).
+
+For: using the embedded server as a durable local database, not a throwaway. Point
+`data_directory` at a fixed folder; the databases and documents live there and are still present
+the next time you start the server against the same folder.
+
+Run: python labs/05_embedded_persistent.py
+"""
+
+import shutil
+import tempfile
+from pathlib import Path
+
+from ravendb_embedded import EmbeddedServer, ServerOptions
+
+
+def _options(data_directory, logs_path):
+ options = ServerOptions()
+ options.data_directory = data_directory
+ options.logs_path = logs_path
+ return options
+
+
+def main() -> None:
+ root = tempfile.mkdtemp()
+ data_directory = str(Path(root, "RavenDB"))
+ logs_path = str(Path(root, "Logs"))
+ try:
+ # First run: write a document, then shut the server down (context exit stops it).
+ with EmbeddedServer() as server:
+ server.start_server(_options(data_directory, logs_path))
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ session.store({"name": "Ayende"}, "people/1")
+ session.save_changes()
+
+ # Second run against the SAME data_directory: the document is still there.
+ with EmbeddedServer() as server:
+ server.start_server(_options(data_directory, logs_path))
+ with server.get_document_store("Lab") as store:
+ with store.open_session() as session:
+ loaded = session.load("people/1", dict)
+ assert loaded is not None and loaded["name"] == "Ayende", loaded
+ finally:
+ shutil.rmtree(root, ignore_errors=True)
+
+ print("Lab 05 OK: the document written in the first run survived a full server restart.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/README.md b/labs/README.md
new file mode 100644
index 0000000..78f2b3c
--- /dev/null
+++ b/labs/README.md
@@ -0,0 +1,19 @@
+# ravendb-embedded: labs
+
+Runnable, self-checking guides for getting a RavenDB server, from most convenient to most
+portable. Each lab ships a script next to it, so you can run the exact code the guide shows.
+
+| Lab | Covers | Needs system .NET? |
+|-----|--------|--------------------|
+| [01](01-embedded-zero-config.md) | Embedded, zero-config (the default) | Yes (.NET 10 for 7.2.x) |
+| [02](02-embedded-external-server.md) | External self-contained server you provide | No |
+| [03](03-on-demand-server.md) | On-demand cached self-contained download (no manual steps) | No |
+| [04](04-embedded-secured.md) | Secured embedded server (HTTPS + client certificate) | Yes |
+| [05](05-embedded-persistent.md) | Persistent data directory (data survives restarts) | Yes |
+
+RavenDB version to .NET mapping: **7.1.x needs .NET 8, 7.2.x needs .NET 10.** The bundled server
+decides this, so it can change on a minor bump; check the lab for your version.
+
+Want the driver to attach to a server you run yourself (Docker, testcontainers, shared CI) instead
+of running one? That is a test-driver feature and has its own lab in the
+[`ravendb-python-testdriver`](https://github.com/ravendb/ravendb-python-testdriver) package.
diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py
index f211727..faac0e1 100644
--- a/ravendb_embedded/__init__.py
+++ b/ravendb_embedded/__init__.py
@@ -1,5 +1,6 @@
from ravendb_embedded.embedded_server import EmbeddedServer
from ravendb_embedded.options import DatabaseOptions, ServerOptions, SecurityOptions
+from ravendb_embedded.on_demand import ensure_server
from ravendb_embedded.provide import (
CopyServerFromNugetProvider,
CopyServerProvider,
diff --git a/ravendb_embedded/on_demand.py b/ravendb_embedded/on_demand.py
new file mode 100644
index 0000000..0b89f19
--- /dev/null
+++ b/ravendb_embedded/on_demand.py
@@ -0,0 +1,109 @@
+import os
+import platform
+import shutil
+import tarfile
+import tempfile
+import urllib.request
+import zipfile
+from pathlib import Path
+
+_DOWNLOAD_BASE = "https://hibernatingrhinos.com/downloads"
+_DOWNLOAD_TIMEOUT_SECONDS = 30
+
+
+def _default_version_line() -> str:
+ # Match the installed package's RavenDB line (e.g. 7.2.5 -> "7.2"); fall back if unknown.
+ try:
+ from importlib.metadata import version
+
+ return ".".join(version("ravendb-embedded").split(".")[:2])
+ except Exception:
+ return "7.2"
+
+
+def _platform_download() -> tuple:
+ machine = platform.machine().lower()
+ arch = "arm64" if machine in ("arm64", "aarch64") else "x64"
+ system = platform.system()
+ if system == "Windows":
+ return f"RavenDB for Windows {arch}", "zip"
+ if system == "Darwin":
+ return f"RavenDB for OSX {arch}", "tar.bz2"
+ return f"RavenDB for Linux {arch}", "tar.bz2"
+
+
+def _extract_safely(archive: Path, dest: Path, extension: str) -> None:
+ # Guard against archive path-traversal (zip/tar slip): every member must resolve inside dest,
+ # and tar links are refused. (The stdlib default extraction is unfiltered before Python 3.12.)
+ dest = dest.resolve()
+
+ def _inside(name: str) -> bool:
+ return (dest / name).resolve() == dest or dest in (dest / name).resolve().parents
+
+ if extension == "zip":
+ with zipfile.ZipFile(archive) as bundle:
+ for name in bundle.namelist():
+ if not _inside(name):
+ raise RuntimeError(f"Refusing to extract unsafe archive path: {name}")
+ bundle.extractall(dest)
+ else:
+ with tarfile.open(archive) as bundle:
+ for member in bundle.getmembers():
+ if member.issym() or member.islnk():
+ raise RuntimeError(f"Refusing to extract a link member from archive: {member.name}")
+ if not _inside(member.name):
+ raise RuntimeError(f"Refusing to extract unsafe archive path: {member.name}")
+ bundle.extractall(dest)
+
+
+def ensure_server(version: str = None, cache_root: str = None) -> str:
+ """Return a local self-contained Server directory, downloading and caching on first use.
+
+ The download is a self-contained build (bundles its own .NET), so it runs with no system
+ .NET. Pulling `latest` for the version line is intentional: a self-contained build never has
+ to match anything on the host. A completed cache entry (keyed on version + platform) is reused
+ and never re-downloaded. Download and extraction happen in a private temp directory that is
+ moved into place only once complete, so an interrupted or concurrent first run never leaves a
+ half-populated cache.
+ """
+ version = version or _default_version_line()
+ root = Path(cache_root) if cache_root else Path.home() / ".cache" / "ravendb-embedded"
+ label, extension = _platform_download()
+ line_dir = root / version
+ target = line_dir / label.replace(" ", "_")
+
+ cached = next(target.rglob("Raven.Server.dll"), None) if target.is_dir() else None
+ if cached:
+ return str(cached.parent)
+
+ line_dir.mkdir(parents=True, exist_ok=True)
+ url = f"{_DOWNLOAD_BASE}/{label.replace(' ', '%20')}/latest?version={version}"
+ work = Path(tempfile.mkdtemp(prefix="download-", dir=line_dir))
+ try:
+ archive = work / f"ravendb.{extension}"
+ try:
+ with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response, open(archive, "wb") as out:
+ shutil.copyfileobj(response, out)
+ except OSError as error:
+ raise RuntimeError(f"Failed to download a self-contained RavenDB server from {url}: {error}") from error
+
+ extracted = work / "server"
+ extracted.mkdir()
+ _extract_safely(archive, extracted, extension)
+ if not next(extracted.rglob("Raven.Server.dll"), None):
+ raise RuntimeError(f"Downloaded archive did not contain a RavenDB server: {url}")
+
+ try:
+ os.replace(extracted, target) # atomic on the same filesystem
+ except OSError:
+ # A concurrent first run may have populated the cache first; prefer a complete entry.
+ if not next(target.rglob("Raven.Server.dll"), None):
+ shutil.rmtree(target, ignore_errors=True)
+ os.replace(extracted, target)
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+ server = next(target.rglob("Raven.Server.dll"), None)
+ if not server:
+ raise RuntimeError(f"Server binaries not found under {target}")
+ return str(server.parent)
diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py
index d483837..9cf4d5b 100644
--- a/ravendb_embedded/options.py
+++ b/ravendb_embedded/options.py
@@ -14,6 +14,7 @@
ExternalServerProvider,
CopyServerFromNugetProvider,
)
+from ravendb_embedded.on_demand import ensure_server
class DatabaseOptions:
@@ -96,3 +97,12 @@ def secured(
def with_external_server(self, server_location: str) -> None:
self.provider = ExternalServerProvider(server_location)
+ # A directory is already a runnable server: run it in place, so we neither copy it nor
+ # collide with the bundled server sitting at the default target location.
+ if os.path.isdir(server_location):
+ self.target_server_location = server_location
+ self.clear_target_server_location = False # never wipe the user's own server directory
+
+ def with_auto_downloaded_server(self, version: str = None, cache_root: str = None) -> None:
+ # Download (once) and cache a self-contained server, then run it with no system .NET.
+ self.with_external_server(ensure_server(version, cache_root))
diff --git a/ravendb_embedded/provide.py b/ravendb_embedded/provide.py
index 373cdee..ecffbb3 100644
--- a/ravendb_embedded/provide.py
+++ b/ravendb_embedded/provide.py
@@ -1,3 +1,4 @@
+import json
import os
import pkgutil
import shutil
@@ -19,6 +20,8 @@ def __init__(self, server_files: str):
self.server_files = server_files
def provide(self, target_directory: str) -> None:
+ if os.path.abspath(self.server_files) == os.path.abspath(target_directory):
+ return # already in place: run the server where it is, nothing to copy
try:
shutil.copytree(self.server_files, target_directory)
except FileExistsError:
@@ -47,7 +50,6 @@ def __init__(self, source_location: str):
self.source_location = source_location
def provide(self, target_directory):
- # Ensure the target directory exists
os.makedirs(target_directory, exist_ok=True)
with open(self.source_location, "rb") as zip_file:
self.unzip(zip_file, target_directory)
@@ -62,15 +64,12 @@ class ExtractFromPkgResourceServerProvider(ProvideRavenDBServer):
def provide(self, target_directory):
resource_name = "ravendb_server.zip"
- # Get binary data from the resource
resource_data = pkgutil.get_data(self.__class__.__module__, resource_name)
if resource_data is None:
raise RuntimeError(f"Unable to find resource: {resource_name}")
- # Create a bytes buffer from the binary data
with BytesIO(resource_data) as bytes_buffer:
- # Call the unzip method to extract contents to the target directory
ExtractFromZipServerProvider.unzip(bytes_buffer.read(), target_directory)
@@ -87,30 +86,50 @@ def __init__(self, server_location: str):
if not os.path.exists(file_server_location):
raise ValueError(f"Server location doesn't exist: {server_location}")
- # Check if target is a file - assuming it is a zip file
if os.path.isfile(file_server_location):
self.inner_provider = ExtractFromZipServerProvider(server_location)
return
- # Alternatively, it might be a directory - look for Raven.Server.exe inside
- if os.path.isdir(file_server_location) and os.path.exists(
- os.path.join(file_server_location, self.SERVER_DLL_FILENAME)
- ):
- self.inner_provider = CopyServerProvider(server_location)
- return
+ # Check self-contained first: it also ships Raven.Server.dll, so a .dll-first check
+ # would misroute it to `dotnet` and force a system .NET install.
+ if os.path.isdir(file_server_location):
+ if self._is_self_contained(file_server_location):
+ self.is_single_file_app = True
+ self.inner_provider = CopyServerProvider(server_location)
+ return
- # Also look for Single File App file - Raven.Server
- if os.path.isdir(file_server_location) and os.path.exists(
- os.path.join(file_server_location, self.SERVER_SFA_FILENAME)
- ):
- self.is_single_file_app = True
- self.inner_provider = CopyServerProvider(server_location)
- return
+ if os.path.exists(os.path.join(file_server_location, self.SERVER_DLL_FILENAME)):
+ self.inner_provider = CopyServerProvider(server_location)
+ return
raise ValueError(
f"Unable to find RavenDB server (expected directory with {self.SERVER_DLL_FILENAME}) or zip file. "
f"Used directory = {server_location}"
)
+ @staticmethod
+ def _is_self_contained(directory: str) -> bool:
+ # Self-contained marker: `includedFrameworks` in the runtime config, or an apphost
+ # present with no managed .dll.
+ runtime_config = os.path.join(directory, "Raven.Server.runtimeconfig.json")
+ if os.path.isfile(runtime_config):
+ try:
+ with open(runtime_config, encoding="utf-8") as config_file:
+ runtime_options = json.load(config_file).get("runtimeOptions", {})
+ if runtime_options.get("includedFrameworks"):
+ return True
+ except (OSError, ValueError):
+ pass
+
+ has_managed_dll = os.path.exists(os.path.join(directory, ExternalServerProvider.SERVER_DLL_FILENAME))
+ has_apphost = any(
+ os.path.exists(os.path.join(directory, name))
+ for name in (
+ ExternalServerProvider.SERVER_SFA_FILENAME,
+ f"{ExternalServerProvider.SERVER_SFA_FILENAME}.exe",
+ )
+ )
+ return has_apphost and not has_managed_dll
+
def provide(self, target_directory: str) -> None:
self.inner_provider.provide(target_directory)
diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py
index bcd6e4c..30923df 100644
--- a/ravendb_embedded/raven_server_runner.py
+++ b/ravendb_embedded/raven_server_runner.py
@@ -6,19 +6,12 @@
from cryptography.hazmat.primitives import hashes
from ravendb.exceptions.raven_exceptions import RavenException
-from ravendb_embedded.provide import ExternalServerProvider
from ravendb_embedded.options import ServerOptions
from ravendb_embedded.runtime_framework_version_matcher import (
RuntimeFrameworkVersionMatcher,
)
-class CommandLineArgumentEscaper:
- @staticmethod
- def escape_single_arg(arg: str) -> str:
- return arg # lol
-
-
class RavenServerRunner:
@staticmethod
def run(options: ServerOptions) -> subprocess.Popen:
@@ -31,8 +24,12 @@ def run(options: ServerOptions) -> subprocess.Popen:
if not options.logs_path.strip():
raise ValueError("logs_path cannot be None or whitespace")
- is_sfa = isinstance(options.provider, ExternalServerProvider) and options.provider.is_single_file_app
- file_name = "Raven.Server" if is_sfa else "Raven.Server.dll"
+ is_sfa = getattr(options.provider, "is_single_file_app", False)
+ if is_sfa:
+ # Self-contained / single-file build: run the native apphost, no `dotnet`.
+ file_name = "Raven.Server.exe" if os.name == "nt" else "Raven.Server"
+ else:
+ file_name = "Raven.Server.dll"
server_paths = [
f"{file_name}",
@@ -54,41 +51,30 @@ def run(options: ServerOptions) -> subprocess.Popen:
if not options.dot_net_path.strip():
raise ValueError("dot_net_path cannot be None or whitespace")
+ # Args are passed to Popen as a list (no shell), so they need no manual escaping.
command_line_args = [
f"--Embedded.ParentProcessId={RavenServerRunner.get_process_id('0')}",
f"--License.Eula.Accepted={'true' if options.accept_eula else 'false'}",
"--Setup.Mode=None",
- f"--DataDir={CommandLineArgumentEscaper.escape_single_arg(options.data_directory)}",
- f"--Logs.Path={CommandLineArgumentEscaper.escape_single_arg(options.logs_path)}",
+ f"--DataDir={options.data_directory}",
+ f"--Logs.Path={options.logs_path}",
]
if options.security:
options.server_url = options.server_url or "https://127.0.0.1:0"
if options.security.server_pfx_certificate_path:
- command_line_args.extend(
- [
- f"--Security.Certificate.Path="
- f"{CommandLineArgumentEscaper.escape_single_arg(options.security.server_pfx_certificate_path)}"
- ]
- )
+ command_line_args.append(f"--Security.Certificate.Path={options.security.server_pfx_certificate_path}")
if options.security.server_pfx_certificate_password:
- command_line_args.extend(
- [
- "--Security.Certificate.Password="
- + CommandLineArgumentEscaper.escape_single_arg(
- options.security.server_pfx_certificate_password
- )
- ]
+ command_line_args.append(
+ f"--Security.Certificate.Password={options.security.server_pfx_certificate_password}"
)
elif options.security.certificate_exec:
command_line_args.extend(
[
- f"--Security.Certificate.Exec="
- f"{CommandLineArgumentEscaper.escape_single_arg(options.security.certificate_exec)}",
- f"--Security.Certificate.Exec.Arguments="
- f"{CommandLineArgumentEscaper.escape_single_arg(options.security.certificate_arguments)}",
+ f"--Security.Certificate.Exec={options.security.certificate_exec}",
+ f"--Security.Certificate.Exec.Arguments={options.security.certificate_arguments}",
]
)
if options.security.client_pem_certificate_path:
@@ -98,12 +84,7 @@ def run(options: ServerOptions) -> subprocess.Popen:
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
thumbprint = cert.fingerprint(hashes.SHA256()).hex()
- command_line_args.extend(
- [
- f"--Security.WellKnownCertificates.Admin="
- f"{CommandLineArgumentEscaper.escape_single_arg(thumbprint)}"
- ]
- )
+ command_line_args.append(f"--Security.WellKnownCertificates.Admin={thumbprint}")
else:
options.server_url = options.server_url or "http://127.0.0.1:0"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 6f4b66e..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-ravendb~=7.2.3
-cryptography>=42.0.0
\ No newline at end of file
diff --git a/scripts/check_dotnet_requirement.py b/scripts/check_dotnet_requirement.py
new file mode 100644
index 0000000..c6dfb5a
--- /dev/null
+++ b/scripts/check_dotnet_requirement.py
@@ -0,0 +1,58 @@
+"""Assert the installed .NET runtime matches what the bundled RavenDB server requires.
+
+Reads the bundled server's runtimeconfig and checks that a matching Microsoft.NETCore.App
+major runtime is installed. This turns the otherwise hidden server-to-.NET coupling (which
+silently jumped from .NET 8 to .NET 10 between 7.1 and 7.2) into an enforced invariant, so CI
+fails loudly instead of passing by luck on a preinstalled runtime.
+
+Run after fetching the bundled server (`python setup.py sdist`):
+ python scripts/check_dotnet_requirement.py
+"""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+RUNTIME_CONFIG = Path(
+ "ravendb_embedded/target/nuget/contentFiles/any/any/RavenDBServer/Raven.Server.runtimeconfig.json"
+)
+
+
+def required_dotnet_major(runtime_config: Path) -> str:
+ options = json.loads(runtime_config.read_text(encoding="utf-8"))["runtimeOptions"]
+ frameworks = list(options.get("frameworks") or options.get("includedFrameworks") or [])
+ if isinstance(options.get("framework"), dict):
+ frameworks.insert(0, options["framework"])
+ for framework in frameworks:
+ if framework.get("name") == "Microsoft.NETCore.App" and framework.get("version"):
+ return framework["version"].split(".")[0]
+ return options.get("tfm", "").removeprefix("net").split(".")[0] # e.g. "net10.0" -> "10"
+
+
+def main() -> int:
+ if not RUNTIME_CONFIG.exists():
+ print(f"Server runtimeconfig not found at {RUNTIME_CONFIG}; fetch the server first (python setup.py sdist).")
+ return 1
+
+ major = required_dotnet_major(RUNTIME_CONFIG)
+ if not major:
+ print(f"Could not determine the required .NET version from {RUNTIME_CONFIG}.")
+ return 1
+
+ try:
+ installed = subprocess.run(["dotnet", "--list-runtimes"], capture_output=True, text=True).stdout
+ except FileNotFoundError:
+ print(f"No 'dotnet' found on PATH; the bundled server requires .NET major {major}.")
+ return 1
+
+ matched = f"Microsoft.NETCore.App {major}." in installed
+ print(f"Bundled server requires .NET major {major}; matching runtime installed: {matched}")
+ if not matched:
+ print(f"Installed .NET runtimes do not include major {major}, which the bundled server requires.")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/setup.py b/setup.py
index a7ac5e9..0bd42ed 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
import urllib.request
from setuptools.command.sdist import sdist
-RAVENDB_VERSION = "7.2.3"
+RAVENDB_VERSION = "7.2.5"
ZIP_FILE_NAME = "server.zip"
RAVENDB_DOWNLOAD_URL = f"https://www.nuget.org/api/v2/package/RavenDB.Embedded/{RAVENDB_VERSION}"
@@ -38,14 +38,15 @@ def run(self):
setup(
- python_requires=">=3.9",
+ python_requires=">=3.10",
cmdclass={"sdist": CustomSDist},
name="ravendb-embedded",
packages=["ravendb_embedded"],
package_dir={"ravendb_embedded": "ravendb_embedded"},
include_package_data=True,
- long_description=open("README.rst").read(),
- version="7.2.3",
+ long_description=open("README.md").read(),
+ long_description_content_type="text/markdown",
+ version="7.2.5",
description="RavenDB Embedded library to run RavenDB in an embedded way",
author="RavenDB",
author_email="support@ravendb.net",
@@ -53,7 +54,7 @@ def run(self):
license="Custom EULA",
keywords="ravendb embedded database nosql doc db",
install_requires=[
- "ravendb~=7.2.3",
+ "ravendb==7.2.3.post1",
"cryptography>=42.0.0",
],
license_files="LICENSE",
diff --git a/tests/__init__.py b/tests/__init__.py
index 81176b6..a9e5908 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,16 +1,4 @@
-import os
-
-
class Person:
def __init__(self, Id: str = None, name: str = None):
self.Id = Id
self.name = name
-
-
-def pin_framework_version(server_options):
- # CI's .NET-version matrix sets RAVENDB_TEST_FRAMEWORK_VERSION to force the server onto a
- # specific runtime (e.g. "10.0.x"); a no-op locally when it's unset.
- framework_version = os.environ.get("RAVENDB_TEST_FRAMEWORK_VERSION")
- if framework_version:
- server_options.framework_version = framework_version
- return server_options
diff --git a/tests/test_basic.py b/tests/test_basic.py
index b3c1f86..526eb08 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -4,7 +4,7 @@
from unittest import TestCase
from ravendb_embedded import EmbeddedServer, ServerOptions, CopyServerFromNugetProvider, DatabaseOptions
-from tests import Person, pin_framework_version
+from tests import Person
class BasicTest(TestCase):
@@ -17,7 +17,6 @@ def test_embedded(self):
server_options.logs_path = str(Path(temp_dir, "Logs"))
server_options.provider = CopyServerFromNugetProvider()
server_options.command_line_args = ["--Features.Availability=Experimental"]
- pin_framework_version(server_options)
embedded.start_server(server_options)
database_options = DatabaseOptions.from_database_name("Test")
@@ -38,7 +37,6 @@ def test_embedded(self):
server_options = ServerOptions()
server_options.data_directory = str(Path(temp_dir, "RavenDB"))
server_options.provider = CopyServerFromNugetProvider()
- pin_framework_version(server_options)
embedded.start_server(server_options)
with embedded.get_document_store("Test") as store:
diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py
index 9880a5d..bae95a7 100644
--- a/tests/test_custom_provider.py
+++ b/tests/test_custom_provider.py
@@ -6,7 +6,7 @@
from ravendb_embedded.embedded_server import EmbeddedServer
from ravendb_embedded.options import ServerOptions, DatabaseOptions
from ravendb_embedded.provide import CopyServerFromNugetProvider
-from tests import Person, pin_framework_version
+from tests import Person
class TestCustomProvider(TestCase):
@@ -16,7 +16,6 @@ def configure_server_options(temp_dir: str, server_options: ServerOptions) -> Se
server_options.data_directory = str(Path(temp_dir, "RavenDB"))
server_options.logs_path = str(Path(temp_dir, "Logs"))
server_options.command_line_args = ["--Features.Availability=Experimental"]
- pin_framework_version(server_options)
return server_options
def test_can_use_zip_as_external_server_source(self):
diff --git a/tests/test_on_demand.py b/tests/test_on_demand.py
new file mode 100644
index 0000000..b3d0f23
--- /dev/null
+++ b/tests/test_on_demand.py
@@ -0,0 +1,52 @@
+import io
+import tarfile
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+from ravendb_embedded.on_demand import _extract_safely, _platform_download, ensure_server
+
+
+class TestOnDemand(unittest.TestCase):
+ def test_cache_hit_skips_download(self):
+ # A populated cache must be reused without any network call ("by design").
+ with tempfile.TemporaryDirectory() as cache_root:
+ label, _ = _platform_download()
+ server_dir = Path(cache_root, "7.2", label.replace(" ", "_"), "Server")
+ server_dir.mkdir(parents=True)
+ (server_dir / "Raven.Server.dll").write_bytes(b"stub")
+
+ with mock.patch("urllib.request.urlopen", side_effect=AssertionError("cache hit must not download")):
+ resolved = ensure_server(version="7.2", cache_root=cache_root)
+
+ self.assertEqual(str(server_dir), resolved)
+
+ def test_platform_download_targets_a_known_os(self):
+ label, extension = _platform_download()
+ self.assertTrue(label.startswith("RavenDB for "))
+ self.assertIn(extension, ("zip", "tar.bz2"))
+
+ def test_extract_rejects_path_traversal(self):
+ # A tampered archive must not be able to write outside the destination (zip/tar slip).
+ with tempfile.TemporaryDirectory() as work:
+ dest = Path(work, "out")
+ dest.mkdir()
+
+ bad_tar = Path(work, "bad.tar.bz2")
+ with tarfile.open(bad_tar, "w:bz2") as tar:
+ payload = b"x"
+ info = tarfile.TarInfo("../escaped.txt")
+ info.size = len(payload)
+ tar.addfile(info, io.BytesIO(payload))
+ with self.assertRaises(RuntimeError):
+ _extract_safely(bad_tar, dest, "tar.bz2")
+
+ bad_zip = Path(work, "bad.zip")
+ with zipfile.ZipFile(bad_zip, "w") as archive:
+ archive.writestr("../escaped.txt", "x")
+ with self.assertRaises(RuntimeError):
+ _extract_safely(bad_zip, dest, "zip")
+
+ self.assertFalse((Path(work) / "escaped.txt").exists())
diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py
index 72feebc..cf8bcf7 100644
--- a/tests/test_secured_basic.py
+++ b/tests/test_secured_basic.py
@@ -6,7 +6,7 @@
from ravendb_embedded.embedded_server import EmbeddedServer
from ravendb_embedded.options import ServerOptions, DatabaseOptions
from ravendb_embedded.provide import CopyServerFromNugetProvider
-from tests import Person, pin_framework_version
+from tests import Person
from tests.certificates import generate_self_signed_certificates
@@ -21,7 +21,6 @@ def test_secured_embedded(self):
server_options.data_directory = str(Path(temp_dir, "RavenDB"))
server_options.logs_path = str(Path(temp_dir, "Logs"))
server_options.provider = CopyServerFromNugetProvider()
- pin_framework_version(server_options)
embedded.start_server(server_options)
database_options = DatabaseOptions.from_database_name("Test")