44import socket
55import subprocess
66import time
7+ import warnings
8+ from pathlib import Path
79
810import pytest
911
10- from bitmapist import delete_all_events , setup_redis
12+ from bitmapist import delete_all_events , get_redis , setup_redis
13+
14+ # Backend types
15+ BACKEND_REDIS = "redis"
16+ BACKEND_BITMAPIST_SERVER = "bitmapist-server"
17+
18+ # Single source of truth for backend configuration
19+ BACKEND_CONFIGS = {
20+ BACKEND_REDIS : {
21+ "port_env" : "BITMAPIST_REDIS_PORT" ,
22+ "default_port" : 6399 ,
23+ "path_env" : "BITMAPIST_REDIS_SERVER_PATH" ,
24+ "binary_name" : "redis-server" ,
25+ "fallback_path" : "/usr/bin/redis-server" ,
26+ "install_hint" : "Install redis-server using your package manager" ,
27+ "start_args" : lambda port : ["--port" , str (port )],
28+ },
29+ BACKEND_BITMAPIST_SERVER : {
30+ "port_env" : "BITMAPIST_SERVER_PORT" ,
31+ "default_port" : 6400 ,
32+ "path_env" : "BITMAPIST_SERVER_PATH" ,
33+ "binary_name" : "bitmapist-server" ,
34+ "fallback_path" : None ,
35+ "install_hint" : "Download from https://github.com/Doist/bitmapist-server/releases" ,
36+ "start_args" : lambda port : ["-addr" , f"0.0.0.0:{ port } " ],
37+ },
38+ }
39+
40+
41+ def is_socket_open (host , port ):
42+ """Helper function which tests is the socket open"""
43+ sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
44+ sock .settimeout (0.1 )
45+ return sock .connect_ex ((host , port )) == 0
46+
47+
48+ @pytest .fixture (scope = "session" )
49+ def available_backends ():
50+ """
51+ Check which backend servers are available on the system.
52+ Checks for running servers (Docker) OR available binaries.
53+ Fails the test suite if NO backends are available.
54+ """
55+ backends = []
56+
57+ for backend_name , config in BACKEND_CONFIGS .items ():
58+ port = int (os .getenv (config ["port_env" ], str (config ["default_port" ])))
59+
60+ # Check if server is already running (Docker or external)
61+ if is_socket_open ("127.0.0.1" , port ):
62+ backends .append (backend_name )
63+ continue
64+
65+ # Check if binary is available
66+ path_str = os .getenv (config ["path_env" ])
67+ if path_str and Path (path_str ).exists ():
68+ backends .append (backend_name )
69+ continue
70+
71+ # Check for binary in PATH or fallback location
72+ if shutil .which (config ["binary_name" ]):
73+ backends .append (backend_name )
74+ continue
75+
76+ if config ["fallback_path" ] and Path (config ["fallback_path" ]).exists ():
77+ backends .append (backend_name )
78+
79+ if not backends :
80+ pytest .fail (
81+ "No backend servers available. Please install redis-server or bitmapist-server.\n "
82+ "Or set BITMAPIST_REDIS_SERVER_PATH or BITMAPIST_SERVER_PATH environment variables."
83+ )
84+
85+ return backends
86+
87+
88+ @pytest .fixture (params = [BACKEND_REDIS , BACKEND_BITMAPIST_SERVER ], scope = "session" )
89+ def backend_type (request ):
90+ """
91+ Parametrized fixture that will cause the entire test suite to run twice:
92+ once with Redis, once with bitmapist-server.
93+ """
94+ return request .param
1195
1296
1397@pytest .fixture (scope = "session" )
14- def redis_settings ():
15- # Find the first redis-server in PATH, fallback to /usr/bin/redis-server
16- default_path = shutil .which ("redis-server" ) or "/usr/bin/redis-server"
98+ def backend_settings (backend_type , available_backends ):
99+ """
100+ Provides backend-specific configuration.
101+ Skips tests if the requested backend is not available.
102+
103+ Uses environment variables to locate binaries:
104+ - BITMAPIST_REDIS_SERVER_PATH: Custom path to redis-server
105+ - BITMAPIST_SERVER_PATH: Custom path to bitmapist-server
106+ - BITMAPIST_REDIS_PORT: Custom port for Redis (default: 6399)
107+ - BITMAPIST_SERVER_PORT: Custom port for bitmapist-server (default: 6400)
108+ """
109+ # Skip if this backend is not available
110+ if backend_type not in available_backends :
111+ pytest .skip (f"{ backend_type } not available on this system" )
112+
113+ config = BACKEND_CONFIGS [backend_type ]
114+
115+ # Try env var first, then auto-detect
116+ default_path = shutil .which (config ["binary_name" ])
117+ if not default_path and config ["fallback_path" ]:
118+ default_path = config ["fallback_path" ]
119+ server_path = os .getenv (config ["path_env" ], default_path or "" )
120+ port = int (os .getenv (config ["port_env" ], str (config ["default_port" ])))
121+
17122 return {
18- "server_path" : os .getenv ("BITMAPIST_REDIS_SERVER_PATH" , default_path ),
19- "port" : int (os .getenv ("BITMAPIST_REDIS_PORT" , "6399" )),
123+ "server_path" : server_path ,
124+ "port" : port ,
125+ "backend_type" : backend_type ,
20126 }
21127
22128
23129@pytest .fixture (scope = "session" , autouse = True )
24- def redis_server (redis_settings ):
25- """Fixture starting the Redis server"""
26- redis_host = "127.0.0.1"
27- redis_port = redis_settings ["port" ]
28- if is_socket_open (redis_host , redis_port ):
130+ def backend_server (backend_settings ):
131+ """
132+ Smart backend server management with auto-detection.
133+
134+ 1. Check if server already running on the port → Use it (Docker/external)
135+ 2. Try to find and start binary → Start it (managed mode)
136+ 3. Nothing available → Fail with helpful error
137+ """
138+ host = "127.0.0.1"
139+ port = backend_settings ["port" ]
140+ backend_type = backend_settings ["backend_type" ]
141+
142+ # Step 1: Check if already running (Docker or external process)
143+ if is_socket_open (host , port ):
29144 yield None
30- else :
31- proc = start_redis_server (redis_settings ["server_path" ], redis_port )
32- # Give Redis a moment to start up
145+ return
146+
147+ # Step 2: Try to find and start binary
148+ server_path = backend_settings .get ("server_path" )
149+ if server_path and Path (server_path ).exists ():
150+ # Binary found, start it
151+ proc = start_backend_server (server_path , port , backend_type )
33152 time .sleep (0.1 )
34- wait_for_socket (redis_host , redis_port )
153+ wait_for_socket (host , port )
35154 yield proc
36155 proc .terminate ()
156+ return
157+
158+ # Step 3: Nothing available - provide helpful error
159+ config = BACKEND_CONFIGS [backend_type ]
160+ pytest .fail (
161+ f"{ backend_type } not available.\n \n "
162+ f"Option 1 (Recommended): Start with Docker\n "
163+ f" docker compose up -d\n \n "
164+ f"Option 2: Install { backend_type } binary\n "
165+ f" { config ['install_hint' ]} \n "
166+ f" Ensure it's in your PATH\n \n "
167+ f"Option 3: Specify binary path\n "
168+ f" export { config ['path_env' ]} =/path/to/{ backend_type } \n \n "
169+ f" pytest"
170+ )
37171
38172
39173@pytest .fixture (scope = "session" , autouse = True )
40- def setup_redis_for_bitmapist (redis_settings ):
41- setup_redis ("default" , "localhost" , redis_settings ["port" ])
42- setup_redis ("default_copy" , "localhost" , redis_settings ["port" ])
43- setup_redis ("db1" , "localhost" , redis_settings ["port" ], db = 1 )
174+ def setup_redis_for_bitmapist (backend_settings ):
175+ """Setup Redis connection for current backend"""
176+ port = backend_settings ["port" ]
177+
178+ setup_redis ("default" , "localhost" , port )
179+ setup_redis ("default_copy" , "localhost" , port )
180+ setup_redis ("db1" , "localhost" , port , db = 1 )
181+
182+
183+ @pytest .fixture (scope = "session" , autouse = True )
184+ def check_existing_data (backend_settings , setup_redis_for_bitmapist ):
185+ """
186+ Check for existing data at session start.
187+ Warns if data exists but doesn't delete it (safety first).
188+ """
189+ cli = get_redis ("default" )
190+ existing_keys = cli .keys ("trackist_*" )
191+
192+ if existing_keys :
193+ warnings .warn (
194+ f"\n { '=' * 70 } \n "
195+ f"WARNING: Found { len (existing_keys )} existing bitmapist keys in backend.\n "
196+ f"Backend: { backend_settings ['backend_type' ]} on port { backend_settings ['port' ]} \n "
197+ f"\n "
198+ f"This may indicate:\n "
199+ f"1. Docker containers with data from previous runs\n "
200+ f"2. Shared backend being used by multiple projects\n "
201+ f"3. Production data in the backend (DANGER!)\n "
202+ f"\n "
203+ f"Tests will continue but results may be affected by existing data.\n "
204+ f"\n "
205+ f"To clean: docker compose down -v (removes volumes), or manually FLUSHDB.\n "
206+ f"{ '=' * 70 } \n " ,
207+ UserWarning ,
208+ stacklevel = 2 ,
209+ )
44210
45211
46212@pytest .fixture (autouse = True )
47213def clean_redis ():
48214 delete_all_events ()
49215
50216
51- def start_redis_server (server_path , port ):
52- """Helper function starting Redis server"""
217+ def start_backend_server (server_path , port , backend_type ):
218+ """Helper function starting backend server ( Redis or bitmapist- server) """
53219 devzero = open (os .devnull )
54220 devnull = open (os .devnull , "w" )
55- command = get_redis_command (server_path , port )
221+ command = get_backend_command (server_path , port , backend_type )
56222 proc = subprocess .Popen (
57223 command ,
58224 stdin = devzero ,
@@ -64,19 +230,13 @@ def start_redis_server(server_path, port):
64230 return proc
65231
66232
67- def get_redis_command (server_path , port ):
68- """Run with --version to determine if this is redis or bitmapist-server"""
69- output = subprocess .check_output ([server_path , "--version" ])
70- if b"bitmapist-server" in output :
71- return [server_path , "-addr" , f"0.0.0.0:{ port } " ]
72- return [server_path , "--port" , str (port )]
73-
74-
75- def is_socket_open (host , port ):
76- """Helper function which tests is the socket open"""
77- sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
78- sock .settimeout (0.1 )
79- return sock .connect_ex ((host , port )) == 0
233+ def get_backend_command (server_path , port , backend_type ):
234+ """
235+ Build the command to start the backend server.
236+ No need to detect which server type - we already know from backend_type.
237+ """
238+ config = BACKEND_CONFIGS [backend_type ]
239+ return [server_path , * config ["start_args" ](port )]
80240
81241
82242def wait_for_socket (host , port , seconds = 3 ):
0 commit comments