diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58341d9..bb68e6a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: - name: Run Lab 05 (persistent data directory) run: python labs/05_embedded_persistent.py - self-contained: + external-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. @@ -82,16 +82,31 @@ jobs: 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: Resolve a self-contained RavenDB server + run: python -c "from ravendb_embedded.on_demand import ensure_server; print(ensure_server(cache_root='server-cache'))" > server-path.txt - name: Run Lab 02 (self-contained, no system .NET) + run: RAVENDB_SELF_CONTAINED_SERVER="$(cat server-path.txt)" python labs/02_embedded_external_server.py + + on-demand: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-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: | - SERVER="$(find server -type d -name Server | head -1)" - RAVENDB_SELF_CONTAINED_SERVER="$SERVER" python labs/02_embedded_external_server.py + python -m pip install --upgrade pip + pip install -e . - - name: Run Lab 03 (on-demand cached self-contained, no system .NET) + - name: Run Lab 03 (on-demand self-contained, no system .NET) run: python labs/03_on_demand_server.py diff --git a/README.md b/README.md index 6e0477e..0721020 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # ravendb-embedded -`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. +`ravendb-embedded` starts a real RavenDB server as a child process managed by your Python program. +You `pip install` it, start the server, and talk to it with the normal `ravendb` client. There is +no separate server to install, configure, or keep running: the server process's lifetime follows +your Python process. Reach for it when you want: @@ -95,14 +95,19 @@ Download the Server package for your platform from the [RavenDB downloads page]( 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: +Or skip the manual download and let the package fetch and cache one for you on first use: ```python options = ServerOptions() options.with_auto_downloaded_server() # downloads + caches a self-contained server, no .NET needed ``` +The package detects the host operating system and architecture at runtime, so the same Python +configuration is portable across supported Windows, Linux, and macOS machines. + Walkthrough: [`labs/03-on-demand-server.md`](labs/03-on-demand-server.md). +Self-contained builds remove the system .NET requirement, but the normal RavenDB OS dependencies +still apply. In particular, minimal Linux images may need their distribution's ICU package. ### Don't manage a server at all (tests) @@ -153,5 +158,6 @@ 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 +The `labs/` folder holds runnable, self-checking guides, one per usage case above. The scripts live +in this repository rather than in the installed wheel, so run them from a checkout. Start at [`labs/README.md`](labs/README.md). diff --git a/labs/01-embedded-zero-config.md b/labs/01-embedded-zero-config.md index d07ca6c..852a317 100644 --- a/labs/01-embedded-zero-config.md +++ b/labs/01-embedded-zero-config.md @@ -43,5 +43,7 @@ with EmbeddedServer() as server: ## 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). +specific major version. If you would rather not manage .NET, use Lab 02 (a self-contained server +you provide) or Lab 03 (one the package downloads and caches). To run RavenDB separately in Docker +and give each test an isolated database, use the testdriver's +[attach lab](https://github.com/ravendb/ravendb-python-testdriver/blob/v7.2/labs/01-attach-to-server.md). diff --git a/labs/02-embedded-external-server.md b/labs/02-embedded-external-server.md index 184796a..3f8f17f 100644 --- a/labs/02-embedded-external-server.md +++ b/labs/02-embedded-external-server.md @@ -41,5 +41,7 @@ A framework-dependent build (only a `framework` reference, no bundled runtime) s ## 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. +No .NET on the box, at the cost of providing and managing the server directory yourself. If you +want the package to download and cache the self-contained server, use Lab 03. To run RavenDB in a +container and attach test databases to it, use the testdriver's +[attach lab](https://github.com/ravendb/ravendb-python-testdriver/blob/v7.2/labs/01-attach-to-server.md). diff --git a/labs/03-on-demand-server.md b/labs/03-on-demand-server.md index 6054476..cc46631 100644 --- a/labs/03-on-demand-server.md +++ b/labs/03-on-demand-server.md @@ -5,6 +5,10 @@ Call `with_auto_downloaded_server()`; on first use the driver fetches a self-con 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`. +The operating system and architecture are detected at runtime. The same Python code therefore +works across supported Windows, Linux, and macOS developer machines and CI runners without +per-platform server paths. + ## Run it ```bash @@ -30,14 +34,17 @@ with EmbeddedServer() as server: 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) +## Version and host requirements + +The package fetches the latest self-contained build for the installed RavenDB version line. Its +bundled runtime removes the host .NET compatibility matrix, and the cache keeps later runs on the +same downloaded build. -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. +Automatic downloads support Windows x64/x86, Linux x64/ARM64, and macOS x64/ARM64. Unsupported +targets fail with an explicit message instead of downloading a build for the wrong architecture. + +Normal RavenDB operating-system dependencies still apply. Standard Linux distributions and hosted +CI images usually include them; minimal Linux images may need their distribution's ICU package. ## Cost to know about @@ -45,7 +52,10 @@ The first use downloads a self-contained build (100 MB+) and the cache keeps it 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). +The cache is not refreshed automatically. To pick up a newer build from the same RavenDB line, +remove that line's cache directory or use a new `cache_root`. + ## Takeaway -`with_auto_downloaded_server()` gives the no-.NET path of Lab 02 with zero manual steps: one call, then -ordinary client code. +`with_auto_downloaded_server()` gives the no-.NET path of Lab 02 without a manual server download +or per-platform configuration: one call, then ordinary client code. diff --git a/labs/03_on_demand_server.py b/labs/03_on_demand_server.py index a1811d4..2bf2079 100644 --- a/labs/03_on_demand_server.py +++ b/labs/03_on_demand_server.py @@ -17,6 +17,7 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() + options.dot_net_path = "__no_dotnet__" options.with_auto_downloaded_server() options.data_directory = str(Path(work, "data")) options.logs_path = str(Path(work, "logs")) diff --git a/labs/README.md b/labs/README.md index 78f2b3c..1331b90 100644 --- a/labs/README.md +++ b/labs/README.md @@ -3,6 +3,10 @@ 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. +The scripts are part of this repository and are not installed into `site-packages`. Clone or +download the repository first, then run the commands below from its root; `pip install` supplies +the released library and server binaries used by the scripts. + | Lab | Covers | Needs system .NET? | |-----|--------|--------------------| | [01](01-embedded-zero-config.md) | Embedded, zero-config (the default) | Yes (.NET 10 for 7.2.x) | diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index fd3eec1..9d89ce8 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -99,9 +99,7 @@ def _try_create_database(self, options: DatabaseOptions, store: DocumentStore) - store.maintenance.server.send(CreateDatabaseOperation(options.database_record)) except Exception as e: # Expected behavior when the database already exists - if ( - "conflict" in e.args[0] or "already exists" in e.args[0] - ): # todo: change exc type when python client will implement conflict handling + if "conflict" in e.args[0] or "already exists" in e.args[0]: self._log_debug(f"{options.database_record.database_name} already exists.") else: raise e @@ -171,7 +169,7 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: ) if url_ref["value"] is None: - error_string = self.read_output(process.stderr, startup_duration, options, None) + error_string = self.read_output(process.stderr, Stopwatch.create_started(), options, None) self._shutdown_server_process(process) raise RuntimeError(self.build_startup_exception_message(output_string, error_string, process)) @@ -199,7 +197,7 @@ def build_startup_exception_message(output_string: str, error_string: str, proce sb.append(output_string) sb.append(os.linesep) - sb.append("Check your ServerOptions, dotnet version, or run the command manually to see detailed error.") + sb.append("Check your ServerOptions and host dependencies, or run the command manually to see detailed error.") return "".join(sb) def online( @@ -212,7 +210,7 @@ def online( options: ServerOptions, ): if line is None: - error_string = self.read_output(process.stderr, startup_duration, options, None) + error_string = self.read_output(process.stderr, Stopwatch.create_started(), options, None) self._shutdown_server_process(process) raise RuntimeError(self.build_startup_exception_message("".join(builder), error_string, process)) @@ -263,6 +261,9 @@ def output_reader(): if line is None: break + if line == self.END_OF_STREAM_MARKER: + break + sb.append(line) sb.append(os.linesep) diff --git a/ravendb_embedded/on_demand.py b/ravendb_embedded/on_demand.py index 0b89f19..80c3107 100644 --- a/ravendb_embedded/on_demand.py +++ b/ravendb_embedded/on_demand.py @@ -21,15 +21,39 @@ def _default_version_line() -> str: return "7.2" -def _platform_download() -> tuple: - machine = platform.machine().lower() - arch = "arm64" if machine in ("arm64", "aarch64") else "x64" - system = platform.system() +def _platform_download_for(system: str, machine: str) -> tuple: + machine = machine.lower() + if machine in ("amd64", "x86_64"): + arch = "x64" + elif machine in ("arm64", "aarch64"): + arch = "arm64" + elif machine in ("x86", "i386", "i686"): + arch = "x86" + else: + raise RuntimeError( + f"Unsupported machine architecture for an automatic RavenDB download: {machine or 'unknown'}" + ) + if system == "Windows": + if arch == "arm64": + raise RuntimeError( + "Automatic RavenDB downloads do not provide a Windows ARM64 build. " + "Use with_external_server() with a compatible server or attach to a separately running RavenDB." + ) 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" + if arch == "x86": + raise RuntimeError("Automatic RavenDB downloads do not provide a 32-bit macOS build.") + return f"RavenDB for MacOS {arch}", "tar.bz2" + if system == "Linux": + if arch == "x86": + raise RuntimeError("Automatic RavenDB downloads do not provide a 32-bit Linux build.") + return f"RavenDB for Linux {arch}", "tar.bz2" + raise RuntimeError(f"Unsupported operating system for an automatic RavenDB download: {system or 'unknown'}") + + +def _platform_download() -> tuple: + return _platform_download_for(platform.system(), platform.machine()) def _extract_safely(archive: Path, dest: Path, extension: str) -> None: @@ -59,12 +83,11 @@ def _inside(name: str) -> bool: 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. + The download is a self-contained build (bundles its own .NET), so it needs no system .NET. + Normal RavenDB operating-system dependencies still apply. 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" diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 9cf4d5b..535b81a 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -93,8 +93,6 @@ def secured( return self - # todo: secured by cert exec and args - 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 diff --git a/setup.py b/setup.py index 0bd42ed..f366edb 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ def run(self): include_package_data=True, long_description=open("README.md").read(), long_description_content_type="text/markdown", - version="7.2.5", + version="7.2.5.post1", description="RavenDB Embedded library to run RavenDB in an embedded way", author="RavenDB", author_email="support@ravendb.net", @@ -57,6 +57,6 @@ def run(self): "ravendb==7.2.3.post1", "cryptography>=42.0.0", ], - license_files="LICENSE", + license_files=["LICENSE"], zip_safe=False, ) diff --git a/tests/test_on_demand.py b/tests/test_on_demand.py index b3d0f23..ec26ff1 100644 --- a/tests/test_on_demand.py +++ b/tests/test_on_demand.py @@ -4,9 +4,8 @@ import unittest import zipfile from pathlib import Path -from unittest import mock -from ravendb_embedded.on_demand import _extract_safely, _platform_download, ensure_server +from ravendb_embedded.on_demand import _extract_safely, _platform_download, _platform_download_for, ensure_server class TestOnDemand(unittest.TestCase): @@ -18,15 +17,37 @@ def test_cache_hit_skips_download(self): 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) + 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_platform_download_maps_supported_targets(self): + cases = [ + ("Windows", "AMD64", ("RavenDB for Windows x64", "zip")), + ("Windows", "x86", ("RavenDB for Windows x86", "zip")), + ("Linux", "x86_64", ("RavenDB for Linux x64", "tar.bz2")), + ("Linux", "aarch64", ("RavenDB for Linux arm64", "tar.bz2")), + ("Darwin", "x86_64", ("RavenDB for MacOS x64", "tar.bz2")), + ("Darwin", "arm64", ("RavenDB for MacOS arm64", "tar.bz2")), + ] + + for system, machine, expected in cases: + with self.subTest(system=system, machine=machine): + self.assertEqual(expected, _platform_download_for(system, machine)) + + def test_platform_download_rejects_unavailable_targets(self): + cases = [ + ("Windows", "arm64"), + ("Linux", "i686"), + ("Darwin", "i386"), + ("FreeBSD", "x86_64"), + ("Linux", "mips"), + ] + + for system, machine in cases: + with self.subTest(system=system, machine=machine): + with self.assertRaises(RuntimeError): + _platform_download_for(system, machine) def test_extract_rejects_path_traversal(self): # A tampered archive must not be able to write outside the destination (zip/tar slip).