Skip to content

Commit a0ca773

Browse files
committed
Refactor Ollama fixture and create Ollama tests
Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com>
1 parent 6a1b09c commit a0ca773

7 files changed

Lines changed: 113 additions & 56 deletions

File tree

tests/conftest.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import subprocess
2-
from typing import Callable
2+
from typing import Callable, Iterator
33

44
import pytest
55

6+
from tests.ollama_utils import DEFAULT_MODEL, model_available, ollama_ready
7+
from tests.process_utils import get_kwargs_for_process_group, terminate_process_group
8+
from tests.wait_utils import wait_until
9+
610
REDIS_CONTAINER = 'dapr_redis'
711

812

@@ -38,3 +42,35 @@ def _set(key: str, value: str, version: int = 1) -> None:
3842
)
3943

4044
return _set
45+
46+
47+
@pytest.fixture(scope='session')
48+
def ollama() -> Iterator[None]:
49+
"""Ensure an Ollama server with the default model is running for the session."""
50+
started: subprocess.Popen[str] | None = None
51+
try:
52+
if not ollama_ready():
53+
try:
54+
started = subprocess.Popen(
55+
['ollama', 'serve'],
56+
stdout=subprocess.DEVNULL,
57+
stderr=subprocess.DEVNULL,
58+
text=True,
59+
**get_kwargs_for_process_group(),
60+
)
61+
except FileNotFoundError as exc:
62+
pytest.fail(f'ollama CLI is not installed: {exc}')
63+
wait_until(ollama_ready, timeout=30.0, interval=0.5)
64+
65+
if not model_available(DEFAULT_MODEL):
66+
subprocess.run(['ollama', 'pull', DEFAULT_MODEL], check=True, capture_output=True)
67+
68+
yield
69+
finally:
70+
if started and started.poll() is None:
71+
terminate_process_group(started)
72+
try:
73+
started.wait(timeout=10)
74+
except subprocess.TimeoutExpired:
75+
terminate_process_group(started, force=True)
76+
started.wait()

tests/examples/test_langgraph_checkpointer.py

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,12 @@
1-
import subprocess
2-
3-
import httpx
41
import pytest
52

6-
from tests.wait_utils import wait_until
7-
8-
OLLAMA_URL = 'http://localhost:11434'
9-
MODEL = 'llama3.2:latest'
10-
113
EXPECTED_LINES = [
124
'Add 3 and 4.',
135
'7',
146
'14',
157
]
168

179

18-
def _ollama_ready() -> bool:
19-
try:
20-
return httpx.get(f'{OLLAMA_URL}/api/tags', timeout=2).is_success
21-
except httpx.RequestError:
22-
return False
23-
24-
25-
def _model_available() -> bool:
26-
try:
27-
resp = httpx.get(f'{OLLAMA_URL}/api/tags', timeout=5)
28-
resp.raise_for_status()
29-
except httpx.RequestError:
30-
return False
31-
32-
return any(m['name'] == MODEL for m in resp.json().get('models', []))
33-
34-
35-
@pytest.fixture()
36-
def ollama():
37-
"""Ensure Ollama is running and the required model is pulled.
38-
39-
Reuses a running instance if available, otherwise starts one for
40-
the duration of the test. Skips if the ollama CLI is not installed.
41-
"""
42-
started: subprocess.Popen[bytes] | None = None
43-
if not _ollama_ready():
44-
try:
45-
started = subprocess.Popen(
46-
['ollama', 'serve'],
47-
stdout=subprocess.DEVNULL,
48-
stderr=subprocess.DEVNULL,
49-
)
50-
except FileNotFoundError:
51-
pytest.skip('ollama is not installed')
52-
wait_until(_ollama_ready, timeout=30.0, interval=0.5)
53-
54-
if not _model_available():
55-
subprocess.run(['ollama', 'pull', MODEL], check=True, capture_output=True)
56-
57-
yield
58-
59-
if started:
60-
started.terminate()
61-
started.wait(timeout=10)
62-
63-
6410
@pytest.mark.example_dir('langgraph-checkpointer')
6511
def test_langgraph_checkpointer(dapr, ollama, flush_redis):
6612
output = dapr.run(

tests/integration/AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ tests/integration/
4040
│ ├── localsecretstore.yaml # secretstores.local.file
4141
│ ├── localbinding.yaml # bindings.localstorage (rootPath=./.binding-data)
4242
│ ├── cryptostore.yaml # crypto.dapr.localstorage (path=./keys)
43-
│ └── conversation.yaml # conversation.echo
43+
│ ├── conversation_echo.yaml # conversation.echo
44+
│ └── conversation_ollama.yaml # conversation.ollama
4445
├── keys/ # RSA + symmetric keys for cryptostore (generated at test time, gitignored)
4546
├── secrets.json # Secrets file for localsecretstore component
4647
└── .binding-data/ # Created on demand for localbinding rootPath (gitignored)
@@ -58,6 +59,7 @@ Sidecar and client fixtures are **module-scoped** — one sidecar per test file.
5859
| `crypto_keys` | session | `Path` | Generates ephemeral RSA + AES keys under `tests/integration/keys/` for the cryptostore component (see `tests/crypto_utils.py`) |
5960
| `flush_redis` | session | `None` | Side-effect fixture that clears the `dapr_redis` container once per session |
6061
| `redis_set_config` | session | `Callable` | Returns `_set(key, value, version=1)` that seeds a Dapr configuration value into Redis (`value||version`) |
62+
| `ollama` | session | `None` | Ensures a local Ollama server with the default model is running; fails the test if the CLI is missing or the server cannot start (helpers in `tests/ollama_utils.py`) |
6163

6264
`flush_redis` and `redis_set_config` are session-scoped (defined in `tests/conftest.py`) so module-scoped fixtures can depend on them.
6365

@@ -86,6 +88,7 @@ Each test file defines its own module-scoped fixture (`client` or `sidecar`) tha
8688
| `test_invoke_binding.py` | Output bindings | `invoke_binding` (create/get/delete against `bindings.localstorage`) |
8789
| `test_crypto.py` | Cryptography | `encrypt`, `decrypt` (RSA + AES round-trips against `crypto.dapr.localstorage`) |
8890
| `test_conversation.py` | Conversation | `converse_alpha1`, `converse_alpha2` against `conversation.echo` |
91+
| `test_conversation_ollama.py` | Conversation (real LLM) | `converse_alpha1`, `converse_alpha2` against `conversation.ollama`; skips if `ollama` CLI is missing |
8992
| `test_workflow.py` | Workflow (`dapr-ext-workflow`) | `WorkflowRuntime`, `DaprWorkflowClient.schedule_new_workflow`, `wait_for_workflow_start`, `wait_for_workflow_completion`, `raise_workflow_event`, `pause_workflow`, `resume_workflow`, `terminate_workflow`, `purge_workflow`, `get_workflow_state` |
9093

9194
### Async client coverage
File renamed without changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: dapr.io/v1alpha1
2+
kind: Component
3+
metadata:
4+
name: ollama
5+
spec:
6+
type: conversation.ollama
7+
version: v1
8+
metadata:
9+
- name: model
10+
value: llama3.2:latest
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
3+
from dapr.clients.grpc.conversation import (
4+
ConversationInput,
5+
ConversationInputAlpha2,
6+
create_user_message,
7+
)
8+
9+
COMPONENT = 'ollama'
10+
11+
12+
@pytest.fixture(scope='module')
13+
def client(dapr_env, ollama):
14+
return dapr_env.start_sidecar(app_id='test-conversation-ollama')
15+
16+
17+
def test_converse_alpha1_returns_non_empty_response(client):
18+
response = client.converse_alpha1(
19+
name=COMPONENT,
20+
inputs=[
21+
ConversationInput(content='Reply with the single word: dapr', role='user'),
22+
],
23+
temperature=0,
24+
)
25+
content = response.outputs[0].result.strip()
26+
assert 'dapr' in content
27+
28+
29+
def test_converse_alpha2_answers_simple_arithmetic(client):
30+
response = client.converse_alpha2(
31+
name=COMPONENT,
32+
inputs=[
33+
ConversationInputAlpha2(
34+
messages=[create_user_message('What is 2 plus 2? Reply with only the number.')]
35+
)
36+
],
37+
temperature=0,
38+
)
39+
content = response.outputs[0].choices[0].message.content
40+
assert '4' in content

tests/ollama_utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
import httpx
4+
5+
OLLAMA_URL = 'http://localhost:11434'
6+
DEFAULT_MODEL = 'llama3.2:latest'
7+
8+
9+
def ollama_ready() -> bool:
10+
try:
11+
return httpx.get(f'{OLLAMA_URL}/api/tags', timeout=2).is_success
12+
except httpx.RequestError:
13+
return False
14+
15+
16+
def model_available(model: str = DEFAULT_MODEL) -> bool:
17+
try:
18+
resp = httpx.get(f'{OLLAMA_URL}/api/tags', timeout=5)
19+
resp.raise_for_status()
20+
except httpx.RequestError:
21+
return False
22+
return any(m['name'] == model for m in resp.json().get('models', []))

0 commit comments

Comments
 (0)