diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b8b9c37..2f89daf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,10 +10,11 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 60 - name: "Python ${{ matrix.python-version }}" + name: "Python ${{ matrix.python-version }} / ${{ matrix.backend }}" strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + backend: ['redis', 'bitmapist-server'] steps: - name: Checkout Repo uses: actions/checkout@v5 @@ -26,11 +27,53 @@ jobs: - name: Install dependencies run: | - uv sync --locked --all-extras --dev + uv sync --locked - - name: Install redis + - name: Install Redis + if: matrix.backend == 'redis' run: | sudo apt-get install redis -y + - name: Get bitmapist-server latest version + if: matrix.backend == 'bitmapist-server' + id: bitmapist-version + run: | + VERSION=$(curl -s https://api.github.com/repos/Doist/bitmapist-server/releases/latest | jq -r .tag_name) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Cache bitmapist-server + if: matrix.backend == 'bitmapist-server' + id: cache-bitmapist + uses: actions/cache@v4 + with: + path: ~/bitmapist-server + key: bitmapist-server-${{ steps.bitmapist-version.outputs.version }} + + - name: Download bitmapist-server + if: matrix.backend == 'bitmapist-server' && steps.cache-bitmapist.outputs.cache-hit != 'true' + run: | + wget https://github.com/Doist/bitmapist-server/releases/latest/download/bitmapist-server-linux-amd64.tar.gz + tar -xzf bitmapist-server-linux-amd64.tar.gz + chmod +x bitmapist-server + mv bitmapist-server ~/bitmapist-server + + - name: Install bitmapist-server + if: matrix.backend == 'bitmapist-server' + run: | + sudo cp ~/bitmapist-server /usr/local/bin/bitmapist-server + sudo chmod +x /usr/local/bin/bitmapist-server + + - name: Verify Redis is available + if: matrix.backend == 'redis' + run: | + command -v redis-server || exit 1 + redis-server --version + + - name: Verify bitmapist-server is available + if: matrix.backend == 'bitmapist-server' + run: | + command -v bitmapist-server || exit 1 + bitmapist-server --version + - name: Run tests run: uv run pytest -vv diff --git a/.gitignore b/.gitignore index 56f025f..24000de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.egg-info /dist .tox -dump.rdb \ No newline at end of file +dump.rdb +bitmapist.db \ No newline at end of file diff --git a/README.md b/README.md index a537096..4cc7357 100644 --- a/README.md +++ b/README.md @@ -342,22 +342,70 @@ uv sync ## Testing -To run our tests will need to ensure a local redis server is installed. +### Quick Start with Docker (Recommended) -You can use these environment variables to tell the tests about Redis: +The easiest way to run tests locally is with Docker: -- `BITMAPIST_REDIS_SERVER_PATH`: Path to the Redis server executable (defaults to the first one in the path or `/usr/bin/redis-server`) -- `BITMAPIST_REDIS_PORT`: Port number for the Redis server (defaults to 6399) +```bash +# Start both backend servers +docker compose up -d + +# Run tests +uv run pytest + +# Stop servers when done +docker compose down +``` + +This runs tests against both Redis and bitmapist-server backends automatically. -We use `pytest` to run unit tests, which you can run with: +### Alternative: Native Binaries +To run tests with native binaries, you'll need at least one backend server installed: + +**Redis:** +- Install `redis-server` using your package manager +- Ensure it's in your `PATH`, or set `BITMAPIST_REDIS_SERVER_PATH` + +**Bitmapist-server:** +- Download from the [releases page](https://github.com/Doist/bitmapist-server/releases) +- Ensure it's in your PATH, or set `BITMAPIST_SERVER_PATH` + +Then run: ```bash uv run pytest ``` -> [!TIP] -> You can also run tests against the [bitmapist-server](https://github.com/Doist/bitmapist-server) backend instead of Redis. -> To do this, set the `BITMAPIST_REDIS_SERVER_PATH` variable to the path of the `bitmapist-server` executable. +The test suite auto-detects available backends and runs accordingly: +- **Docker containers running?** Uses them +- **Native binaries available?** Starts them automatically +- **Nothing available?** Shows error + +### Configuration + +#### Environment Variables + +Customize backend locations and ports if needed: + +```bash +# Backend binary paths (optional - auto-detected from PATH by default) +export BITMAPIST_REDIS_SERVER_PATH=/custom/path/to/redis-server +export BITMAPIST_SERVER_PATH=/custom/path/to/bitmapist-server + +# Backend ports (optional - defaults shown) +export BITMAPIST_REDIS_PORT=6399 +export BITMAPIST_SERVER_PORT=6400 +``` + +#### Testing Specific Backends + +```bash +# Test only Redis +uv run pytest -k redis + +# Test only bitmapist-server +uv run pytest -k bitmapist-server +``` ## Releasing new versions diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b71d379 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +# This file is used to start the Redis and bitmapist-server services for local +# development and testing. + +services: + redis: + image: redis:7-alpine + ports: + - "${BITMAPIST_REDIS_PORT:-6399}:6379" + command: redis-server --port 6379 + + bitmapist-server: + image: ghcr.io/doist/bitmapist-server:v1.9.8 + ports: + - "${BITMAPIST_SERVER_PORT:-6400}:6379" diff --git a/test/conftest.py b/test/conftest.py index 092c9bb..5ab03df 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,46 +1,286 @@ +from __future__ import annotations + import atexit import os import shutil import socket import subprocess import time +import warnings +from dataclasses import dataclass +from pathlib import Path +from typing import Callable import pytest -from bitmapist import delete_all_events, setup_redis +from bitmapist import delete_all_events, get_redis, setup_redis + +# Backend types +BACKEND_REDIS = "redis" +BACKEND_BITMAPIST_SERVER = "bitmapist-server" + +# Safety threshold for existing keys +SAFE_EXISTING_KEY_THRESHOLD = 20 + + +@dataclass +class BackendConfig: + """Configuration for a backend server""" + + port_env: str + default_port: int + path_env: str + binary_name: str + fallback_path: str | None + install_hint: str + start_args: Callable[[int, Path], list[str]] + + +@dataclass +class BackendStatus: + """Status of a backend server""" + + available: bool + mode: str | None # "docker", "native", or None + port: int + binary_path: str | None + + +# Single source of truth for backend configuration +BACKEND_CONFIGS = { + BACKEND_REDIS: BackendConfig( + port_env="BITMAPIST_REDIS_PORT", + default_port=6399, + path_env="BITMAPIST_REDIS_SERVER_PATH", + binary_name="redis-server", + fallback_path="/usr/bin/redis-server", + install_hint="Install redis-server using your package manager", + start_args=lambda port, temp_dir: [ + "--port", + str(port), + "--dir", + str(temp_dir), + "--dbfilename", + "redis.rdb", + ], + ), + BACKEND_BITMAPIST_SERVER: BackendConfig( + port_env="BITMAPIST_SERVER_PORT", + default_port=6400, + path_env="BITMAPIST_SERVER_PATH", + binary_name="bitmapist-server", + fallback_path=None, + install_hint="Download from https://github.com/Doist/bitmapist-server/releases", + start_args=lambda port, temp_dir: [ + "-addr", + f"0.0.0.0:{port}", + "-db", + str(temp_dir / "bitmapist.db"), + ], + ), +} + + +def is_socket_open(host, port): + """Helper function which tests is the socket open""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.1) + return sock.connect_ex((host, port)) == 0 + + +def get_backend_status(backend_config): + """ + Check if a backend is available and how. + + Returns BackendStatus with: + - available: bool + - mode: "docker", "native", or None + - port: int + - binary_path: str or None + """ + port = int(os.getenv(backend_config.port_env, str(backend_config.default_port))) + + # Check if already running (Docker or external) + if is_socket_open("127.0.0.1", port): + return BackendStatus( + available=True, + mode="docker", + port=port, + binary_path=None, + ) + + # Check for binary + binary_path = os.getenv(backend_config.path_env) + if not binary_path: + binary_path = shutil.which(backend_config.binary_name) + if not binary_path and backend_config.fallback_path: + binary_path = backend_config.fallback_path + + if binary_path and Path(binary_path).exists(): + return BackendStatus( + available=True, + mode="native", + port=port, + binary_path=binary_path, + ) + + return BackendStatus( + available=False, + mode=None, + port=port, + binary_path=None, + ) @pytest.fixture(scope="session") -def redis_settings(): - # Find the first redis-server in PATH, fallback to /usr/bin/redis-server - default_path = shutil.which("redis-server") or "/usr/bin/redis-server" +def available_backends(): + """ + Check which backend servers are available on the system. + Checks for running servers (Docker) OR available binaries. + Fails the test suite if NO backends are available. + """ + backends = [] + + for backend_name, config in BACKEND_CONFIGS.items(): + status = get_backend_status(config) + if status.available: + backends.append(backend_name) + + if not backends: + pytest.fail( + "No backend servers available. Please install redis-server or bitmapist-server.\n" + "Or set BITMAPIST_REDIS_SERVER_PATH or BITMAPIST_SERVER_PATH environment variables." + ) + + return backends + + +@pytest.fixture(params=[BACKEND_REDIS, BACKEND_BITMAPIST_SERVER], scope="session") +def backend_type(request): + """ + Parametrized fixture that will cause the entire test suite to run twice: + once with Redis, once with bitmapist-server. + """ + return request.param + + +@pytest.fixture(scope="session") +def backend_settings(backend_type, available_backends): + """ + Provides backend-specific configuration. + Skips tests if the requested backend is not available. + + Uses environment variables to locate binaries: + - BITMAPIST_REDIS_SERVER_PATH: Custom path to redis-server + - BITMAPIST_SERVER_PATH: Custom path to bitmapist-server + - BITMAPIST_REDIS_PORT: Custom port for Redis (default: 6399) + - BITMAPIST_SERVER_PORT: Custom port for bitmapist-server (default: 6400) + """ + # Skip if this backend is not available + if backend_type not in available_backends: + pytest.skip(f"{backend_type} not available on this system") + + config = BACKEND_CONFIGS[backend_type] + + # Try env var first, then auto-detect + default_path = shutil.which(config.binary_name) + if not default_path and config.fallback_path: + default_path = config.fallback_path + server_path = os.getenv(config.path_env, default_path or "") + port = int(os.getenv(config.port_env, str(config.default_port))) + return { - "server_path": os.getenv("BITMAPIST_REDIS_SERVER_PATH", default_path), - "port": int(os.getenv("BITMAPIST_REDIS_PORT", "6399")), + "server_path": server_path, + "port": port, + "backend_type": backend_type, } @pytest.fixture(scope="session", autouse=True) -def redis_server(redis_settings): - """Fixture starting the Redis server""" - redis_host = "127.0.0.1" - redis_port = redis_settings["port"] - if is_socket_open(redis_host, redis_port): +def backend_server(backend_settings, tmp_path_factory): + """ + Smart backend server management with auto-detection. + + 1. Check if server already running on the port → Use it (Docker/external) + 2. Try to find and start binary → Start it (managed mode) + 3. Nothing available → Fail with helpful error + """ + host = "127.0.0.1" + port = backend_settings["port"] + backend_type = backend_settings["backend_type"] + + # Step 1: Check if already running (Docker or external process) + if is_socket_open(host, port): yield None - else: - proc = start_redis_server(redis_settings["server_path"], redis_port) - # Give Redis a moment to start up - time.sleep(0.1) - wait_for_socket(redis_host, redis_port) + return + + # Step 2: Try to find and start binary + server_path = backend_settings.get("server_path") + if server_path and Path(server_path).exists(): + # Binary found, start it + temp_dir = tmp_path_factory.mktemp(f"{backend_type}-data") + config = BACKEND_CONFIGS[backend_type] + command = [server_path, *config.start_args(port, temp_dir)] + proc = start_backend_server(command) + wait_for_socket(host, port) yield proc proc.terminate() + return + + # Step 3: Nothing available - provide helpful error + config = BACKEND_CONFIGS[backend_type] + pytest.fail( + f"{backend_type} not available.\n\n" + f"Option 1 (Recommended): Start with Docker\n" + f" docker compose up -d\n\n" + f"Option 2: Install {backend_type} binary\n" + f" {config.install_hint}\n" + f" Ensure it's in your PATH\n\n" + f"Option 3: Specify binary path\n" + f" export {config.path_env}=/path/to/{backend_type}\n\n" + f" pytest" + ) @pytest.fixture(scope="session", autouse=True) -def setup_redis_for_bitmapist(redis_settings): - setup_redis("default", "localhost", redis_settings["port"]) - setup_redis("default_copy", "localhost", redis_settings["port"]) - setup_redis("db1", "localhost", redis_settings["port"], db=1) +def setup_redis_for_bitmapist(backend_settings): + """Setup Redis connection for current backend""" + port = backend_settings["port"] + + setup_redis("default", "localhost", port) + setup_redis("default_copy", "localhost", port) + setup_redis("db1", "localhost", port, db=1) + + +@pytest.fixture(scope="session", autouse=True) +def check_existing_data(backend_settings, setup_redis_for_bitmapist): + """ + Check for existing data at session start. + Fails if too many keys exist (likely production data). + Warns if small number of keys from previous test runs. + """ + cli = get_redis("default") + existing_keys = cli.keys("trackist_*") + + if not existing_keys: + return + + backend = f"{backend_settings['backend_type']}:{backend_settings['port']}" + + if len(existing_keys) > SAFE_EXISTING_KEY_THRESHOLD: + pytest.fail( + f"Found {len(existing_keys)} existing bitmapist keys in {backend}. " + f"This exceeds safe threshold ({SAFE_EXISTING_KEY_THRESHOLD}). " + f"Refusing to run tests to avoid data loss." + ) + + # Below threshold - just warn + warnings.warn( + f"Found {len(existing_keys)} existing keys in {backend} " + f"(likely from previous test runs). These will be deleted during test cleanup.", + UserWarning, + stacklevel=2, + ) @pytest.fixture(autouse=True) @@ -48,11 +288,31 @@ def clean_redis(): delete_all_events() -def start_redis_server(server_path, port): - """Helper function starting Redis server""" +def pytest_report_header(config): + """Add backend information to pytest header""" + headers = [] + + for backend_name, backend_config in BACKEND_CONFIGS.items(): + status = get_backend_status(backend_config) + + if status.mode == "docker": + headers.append( + f"{backend_name}: Docker/external server on port {status.port}" + ) + elif status.mode == "native": + headers.append( + f"{backend_name}: Native binary ({status.binary_path}) on port {status.port}" + ) + else: + headers.append(f"{backend_name}: Not available") + + return headers + + +def start_backend_server(command): + """Helper function starting backend server (Redis or bitmapist-server)""" devzero = open(os.devnull) devnull = open(os.devnull, "w") - command = get_redis_command(server_path, port) proc = subprocess.Popen( command, stdin=devzero, @@ -64,33 +324,18 @@ def start_redis_server(server_path, port): return proc -def get_redis_command(server_path, port): - """Run with --version to determine if this is redis or bitmapist-server""" - output = subprocess.check_output([server_path, "--version"]) - if b"bitmapist-server" in output: - return [server_path, "-addr", f"0.0.0.0:{port}"] - return [server_path, "--port", str(port)] - - -def is_socket_open(host, port): - """Helper function which tests is the socket open""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(0.1) - return sock.connect_ex((host, port)) == 0 - - -def wait_for_socket(host, port, seconds=3): +def wait_for_socket(host, port, seconds=10): """Check if socket is up for :param:`seconds` sec, raise an error otherwise""" polling_interval = 0.1 iterations = int(seconds / polling_interval) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(0.1) for _ in range(iterations): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.1) result = sock.connect_ex((host, port)) + sock.close() if result == 0: - sock.close() - break + return time.sleep(polling_interval) - else: - raise RuntimeError(f"Service at {host}:{port} is unreachable") + + raise RuntimeError(f"Service at {host}:{port} is unreachable") diff --git a/test/test_bitmapist.py b/test/test_bitmapist.py index f462e52..f255087 100644 --- a/test/test_bitmapist.py +++ b/test/test_bitmapist.py @@ -1,5 +1,7 @@ from datetime import datetime, timedelta, timezone +import pytest + from bitmapist import ( BitOpAnd, BitOpOr, @@ -247,6 +249,8 @@ def test_bit_operations_magic(): assert list(~foo & bar) == [3] -def test_year_events(): +def test_year_events(backend_type): + if backend_type == "bitmapist-server": + pytest.skip("bitmapist-server does not support multiple databases (db != 0)") mark_event("foo", 1, system="db1") assert 1 in YearEvents("foo", system="db1")