Skip to content

Commit 775dd47

Browse files
committed
improve disabled-store test reliability.
Signed-off-by: Matt, Matthias <matthias.matt@tuwien.ac.at>
1 parent 63fe27d commit 775dd47

7 files changed

Lines changed: 156 additions & 129 deletions

File tree

scripts/gen_inference_store_disabled_recordings.py

Lines changed: 86 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,25 @@
2828
import sys
2929
import tempfile
3030
import threading
31+
from collections.abc import Iterator
32+
from contextlib import contextmanager
3133
from http.server import BaseHTTPRequestHandler, HTTPServer
34+
from typing import Any
3235

3336
import yaml
3437

3538
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
3639
sys.path.insert(0, os.path.join(REPO_ROOT, "src"))
3740
sys.path.insert(0, REPO_ROOT)
3841

39-
from ogx.core.library_client import OGXAsLibraryClient # noqa: E402
40-
from ogx.core.stack import get_stack_run_config_from_distro # noqa: E402
41-
from ogx.core.testing_context import set_test_context # noqa: E402
42-
from tests.integration.inference.store_disabled_constants import ( # noqa: E402
42+
from ogx.core.library_client import OGXAsLibraryClient # noqa: E402 # Requires local checkout path setup above.
43+
from ogx.core.testing_context import set_test_context # noqa: E402 # Requires local checkout path setup above.
44+
from tests.integration.inference.store_disabled_support import ( # noqa: E402 # Requires path setup above.
4345
NON_STREAMING_PROMPT,
4446
RECORDING_TEST_IDS,
4547
STREAMING_PROMPT,
4648
TEXT_MODEL,
49+
build_inference_store_disabled_run_config,
4750
)
4851

4952
RECORDINGS_DIR = os.path.join(REPO_ROOT, "tests", "integration", "inference", "recordings")
@@ -55,7 +58,7 @@
5558
MOCK_PORT = 0 # ephemeral
5659

5760

58-
def _completion_body(model: str, prompt: str, completion_id: str) -> dict:
61+
def _completion_body(model: str, prompt: str, completion_id: str) -> dict[str, Any]:
5962
return {
6063
"id": completion_id,
6164
"object": "chat.completion",
@@ -72,7 +75,7 @@ def _completion_body(model: str, prompt: str, completion_id: str) -> dict:
7275
}
7376

7477

75-
def _stream_chunks(model: str, completion_id: str):
78+
def _stream_chunks(model: str, completion_id: str) -> list[dict[str, Any]]:
7679
return [
7780
{
7881
"id": completion_id,
@@ -105,7 +108,7 @@ def _stream_chunks(model: str, completion_id: str):
105108

106109

107110
class MockHandler(BaseHTTPRequestHandler):
108-
def do_GET(self): # noqa: N802 Function name `do_GET` should be lowercase
111+
def do_GET(self) -> None: # noqa: N802 Function name `do_GET` should be lowercase
109112
if self.path.endswith("/models"):
110113
body = json.dumps({"object": "list", "data": [{"id": "gpt-4o", "object": "model"}]}).encode()
111114
self.send_response(200)
@@ -117,13 +120,14 @@ def do_GET(self): # noqa: N802 Function name `do_GET` should be lowercase
117120
self.send_response(404)
118121
self.end_headers()
119122

120-
def do_POST(self): # noqa: N802 Function name `do_POST` should be lowercase
123+
def do_POST(self) -> None: # noqa: N802 Function name `do_POST` should be lowercase
121124
length = int(self.headers.get("Content-Length", "0"))
122125
raw = self.rfile.read(length) if length else b""
123126
try:
124-
payload = json.loads(raw) if raw else {}
125-
except Exception:
126-
payload = {}
127+
decoded_payload = json.loads(raw) if raw else {}
128+
except (json.JSONDecodeError, UnicodeDecodeError):
129+
decoded_payload = {}
130+
payload = decoded_payload if isinstance(decoded_payload, dict) else {}
127131
stream = payload.get("stream", False)
128132
model = payload.get("model", "gpt-4o")
129133
completion_id = "chatcmpl-mock-recording"
@@ -132,8 +136,8 @@ def do_POST(self): # noqa: N802 Function name `do_POST` should be lowercase
132136
self.send_response(200)
133137
self.send_header("Content-Type", "text/event-stream")
134138
self.end_headers()
135-
for ch in chunks:
136-
self.wfile.write(f"data: {json.dumps(ch)}\n\n".encode())
139+
for chunk in chunks:
140+
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
137141
self.wfile.flush()
138142
self.wfile.write(b"data: [DONE]\n\n")
139143
self.wfile.flush()
@@ -146,18 +150,24 @@ def do_POST(self): # noqa: N802 Function name `do_POST` should be lowercase
146150
self.end_headers()
147151
self.wfile.write(data)
148152

149-
def log_message(self, *args): # silence
153+
def log_message(self, format: str, *args: Any) -> None: # silence
150154
pass
151155

152156

153-
def start_mock_server() -> HTTPServer:
157+
@contextmanager
158+
def _mock_server() -> Iterator[HTTPServer]:
154159
server = HTTPServer((MOCK_HOST, MOCK_PORT), MockHandler)
155160
thread = threading.Thread(target=server.serve_forever, daemon=True)
156161
thread.start()
157-
return server
162+
try:
163+
yield server
164+
finally:
165+
server.shutdown()
166+
server.server_close()
167+
thread.join()
158168

159169

160-
async def run(test_id: str, config_path: str) -> None:
170+
async def _run(test_id: str, config_path: str) -> None:
161171
set_test_context(test_id)
162172
client = OGXAsLibraryClient(config_path, skip_logger_removal=True)
163173
try:
@@ -177,70 +187,72 @@ async def run(test_id: str, config_path: str) -> None:
177187
client.shutdown()
178188

179189

180-
def main() -> None:
181-
os.environ["OGX_TEST_INFERENCE_MODE"] = "record"
182-
os.environ["OGX_LOGGING"] = "all=warning"
183-
os.environ["OPENAI_API_KEY"] = "fake-key-for-replay"
184-
sqlite_dir = tempfile.mkdtemp(prefix="ogx-record-")
185-
os.environ["SQLITE_STORE_DIR"] = sqlite_dir
186-
187-
server = start_mock_server()
188-
port = server.server_address[1]
189-
mock_base = f"http://{MOCK_HOST}:{port}/v1"
190-
os.environ["OPENAI_BASE_URL"] = mock_base
191-
192-
run_config = get_stack_run_config_from_distro("ci-tests")
193-
run_config.storage.stores.inference = None
194-
run_config.vector_stores = None
195-
196-
config_file = os.path.join(tempfile.mkdtemp(), "run.yaml")
197-
with open(config_file, "w") as f:
198-
yaml.dump(run_config.model_dump(mode="json"), f)
199-
200-
# Isolate recording so the shared inference recordings directory is untouched.
201-
# The recorder always writes into the test file's ``recordings/`` dir (relative
202-
# to CWD) when a test context is set, so move the real directory aside while
203-
# recording and merge only the chat-completion recordings back afterwards.
204-
backup = None
205-
if os.path.isdir(RECORDINGS_DIR):
206-
backup = RECORDINGS_DIR + ".bak"
207-
shutil.move(RECORDINGS_DIR, backup)
208-
209-
try:
210-
for test_id in TEST_NODE_IDS:
211-
print(f"recording for {test_id} ...")
212-
asyncio.run(run(test_id, config_file))
213-
finally:
214-
server.shutdown()
215-
216-
# Collect the freshly recorded chat-completion recordings (skip models-list
217-
# recordings -- the test does not need them in replay mode) and rewrite their
218-
# mock-host URLs to the canonical provider URL. The recording hash ignores
219-
# the host, so replay works against the real provider URL.
220-
staged = tempfile.mkdtemp(prefix="ogx-staged-")
190+
def _stage_recordings(staged_dir: str) -> int:
191+
"""Copy generated chat recordings to staging and normalize their provider URLs."""
221192
for name in os.listdir(RECORDINGS_DIR):
222193
if not name.endswith(".json") or name.startswith("models-"):
223194
continue
224-
src = os.path.join(RECORDINGS_DIR, name)
225-
with open(src) as f:
226-
data = json.load(f)
195+
source = os.path.join(RECORDINGS_DIR, name)
196+
with open(source, encoding="utf-8") as file:
197+
data = json.load(file)
227198
url = data.get("request", {}).get("url", "")
228199
if re.search(r"\d+\.\d+\.\d+\.\d+:\d+", url):
229200
data["request"]["url"] = "https://api.openai.com/v1" + url.split("/v1", 1)[1]
230-
with open(os.path.join(staged, name), "w") as f:
231-
json.dump(data, f, indent=2)
232-
f.write("\n")
233-
234-
# Restore the original recordings directory and drop in the new recordings.
235-
shutil.rmtree(RECORDINGS_DIR, ignore_errors=True)
236-
if backup is not None:
237-
shutil.move(backup, RECORDINGS_DIR)
238-
else:
201+
with open(os.path.join(staged_dir, name), "w", encoding="utf-8") as file:
202+
json.dump(data, file, indent=2)
203+
file.write("\n")
204+
return len(os.listdir(staged_dir))
205+
206+
207+
def _generate_recordings(config_file: str) -> int:
208+
"""Generate recordings while preserving the repository's existing fixtures."""
209+
recordings_parent = os.path.dirname(RECORDINGS_DIR)
210+
with (
211+
tempfile.TemporaryDirectory(prefix=".recordings-backup-", dir=recordings_parent) as backup_dir,
212+
tempfile.TemporaryDirectory(prefix="ogx-staged-") as staged_dir,
213+
):
214+
original_recordings = os.path.join(backup_dir, "recordings")
215+
had_original_recordings = os.path.isdir(RECORDINGS_DIR)
216+
if had_original_recordings:
217+
shutil.move(RECORDINGS_DIR, original_recordings)
218+
219+
try:
220+
for test_id in TEST_NODE_IDS:
221+
print(f"recording for {test_id} ...")
222+
asyncio.run(_run(test_id, config_file))
223+
n_written = _stage_recordings(staged_dir)
224+
finally:
225+
shutil.rmtree(RECORDINGS_DIR, ignore_errors=True)
226+
if had_original_recordings:
227+
shutil.move(original_recordings, RECORDINGS_DIR)
228+
239229
os.makedirs(RECORDINGS_DIR, exist_ok=True)
240-
n_written = len(os.listdir(staged))
241-
for name in os.listdir(staged):
242-
shutil.copy2(os.path.join(staged, name), os.path.join(RECORDINGS_DIR, name))
243-
shutil.rmtree(staged, ignore_errors=True)
230+
for name in os.listdir(staged_dir):
231+
shutil.copy2(os.path.join(staged_dir, name), os.path.join(RECORDINGS_DIR, name))
232+
return n_written
233+
234+
235+
def main() -> None:
236+
os.environ["OGX_TEST_INFERENCE_MODE"] = "record"
237+
os.environ["OGX_LOGGING"] = "all=warning"
238+
os.environ["OPENAI_API_KEY"] = "fake-key-for-replay"
239+
240+
with (
241+
tempfile.TemporaryDirectory(prefix="ogx-record-") as sqlite_dir,
242+
tempfile.TemporaryDirectory(prefix="ogx-config-") as config_dir,
243+
_mock_server() as server,
244+
):
245+
os.environ["SQLITE_STORE_DIR"] = sqlite_dir
246+
port = server.server_address[1]
247+
os.environ["OPENAI_BASE_URL"] = f"http://{MOCK_HOST}:{port}/v1"
248+
249+
run_config = build_inference_store_disabled_run_config()
250+
config_file = os.path.join(config_dir, "run.yaml")
251+
with open(config_file, "w", encoding="utf-8") as file:
252+
yaml.safe_dump(run_config.model_dump(mode="json"), file)
253+
254+
n_written = _generate_recordings(config_file)
255+
244256
print(f"wrote {n_written} recordings")
245257
print("done")
246258

src/ogx/core/routers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async def get_auto_router_impl(
6666
# missing store (it guards every write and raises NotImplementedError
6767
# on the history endpoints), mirroring the optional Responses store.
6868
inference_ref = run_config.storage.stores.inference
69-
if inference_ref:
69+
if inference_ref is not None:
7070
inference_store = InferenceStore(
7171
reference=inference_ref,
7272
policy=policy,

tests/integration/inference/store_disabled_constants.py renamed to tests/integration/inference/store_disabled_support.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# This source code is licensed under the terms described in the LICENSE file in
55
# the root directory of this source tree.
66

7-
"""Shared constants for the inference-store-disabled tests and their recording generator.
7+
"""Shared support for the inference-store-disabled tests and recording generator.
88
99
The recording harness keys recordings by a SHA256 hash of the request body and the
1010
pytest node id, so the model id, prompts, and node ids MUST match between
@@ -13,6 +13,9 @@
1313
prevents silent drift between the test and its regenerable recordings.
1414
"""
1515

16+
from ogx.core.datatypes import StackConfig
17+
from ogx.core.stack import get_stack_run_config_from_distro
18+
1619
TEXT_MODEL = "openai/gpt-4o"
1720

1821
NON_STREAMING_PROMPT = "Say hello."
@@ -29,3 +32,13 @@
2932
MESSAGES_TEST = f"{TEST_MODULE}::test_list_chat_completion_messages_reports_not_configured"
3033

3134
RECORDING_TEST_IDS = [NON_STREAMING_TEST, STREAMING_TEST, RETRIEVE_TEST, MESSAGES_TEST]
35+
36+
37+
def build_inference_store_disabled_run_config() -> StackConfig:
38+
"""Build the minimal ci-tests configuration used by this test scenario."""
39+
run_config = get_stack_run_config_from_distro("ci-tests")
40+
run_config.storage.stores.inference = None
41+
# Vector-store model validation is unrelated to chat completion persistence
42+
# and loads the sentence-transformers stack during an in-process boot.
43+
run_config.vector_stores = None
44+
return run_config

0 commit comments

Comments
 (0)