pytest-fakellm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_fakellm-0.1.0/.gitignore +9 -0
- pytest_fakellm-0.1.0/PKG-INFO +124 -0
- pytest_fakellm-0.1.0/README.md +90 -0
- pytest_fakellm-0.1.0/pyproject.toml +56 -0
- pytest_fakellm-0.1.0/src/pytest_fakellm/__init__.py +8 -0
- pytest_fakellm-0.1.0/src/pytest_fakellm/plugin.py +130 -0
- pytest_fakellm-0.1.0/src/pytest_fakellm/server.py +256 -0
- pytest_fakellm-0.1.0/tests/test_plugin_usage.py +74 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-fakellm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pytest fixtures for the fakellm mock OpenAI/Anthropic server — spin up, reset, and assert with zero boilerplate.
|
|
5
|
+
Project-URL: Homepage, https://github.com/1dg618/pytest-fakellm
|
|
6
|
+
Project-URL: Repository, https://github.com/1dg618/pytest-fakellm
|
|
7
|
+
Project-URL: Issues, https://github.com/1dg618/pytest-fakellm/issues
|
|
8
|
+
Author-email: Douglas Gregor <1dg618@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: anthropic,fakellm,fixtures,llm,mock,openai,pytest,testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Pytest
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: fakellm>=0.3.1
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pytest>=7.0
|
|
26
|
+
Provides-Extra: anthropic
|
|
27
|
+
Requires-Dist: anthropic>=0.34; extra == 'anthropic'
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: anthropic>=0.34; extra == 'dev'
|
|
30
|
+
Requires-Dist: openai>=1.40; extra == 'dev'
|
|
31
|
+
Provides-Extra: openai
|
|
32
|
+
Requires-Dist: openai>=1.40; extra == 'openai'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# pytest-fakellm
|
|
36
|
+
|
|
37
|
+
Pytest fixtures for [fakellm](https://github.com/dgregor/fakellm), the mock
|
|
38
|
+
OpenAI/Anthropic server. Spin up a server, get a clean state per test, and
|
|
39
|
+
assert on what your code sent — with zero boilerplate.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install pytest-fakellm
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Once installed, the fixtures are available automatically — no imports, no
|
|
46
|
+
`conftest.py` setup.
|
|
47
|
+
|
|
48
|
+
## The point
|
|
49
|
+
|
|
50
|
+
Without the plugin, using fakellm in a test means starting the server, wiring a
|
|
51
|
+
client to its URL, resetting state, and tearing it all down yourself, in every
|
|
52
|
+
test. With the plugin, that becomes:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
def test_agent_handles_search(fakellm):
|
|
56
|
+
fakellm.set_config_text("""
|
|
57
|
+
version: 1
|
|
58
|
+
rules:
|
|
59
|
+
- name: summarize
|
|
60
|
+
when: { messages_contain: "research" }
|
|
61
|
+
respond: { content: "Based on the search, I found what you were looking for." }
|
|
62
|
+
""")
|
|
63
|
+
result = run_my_agent(fakellm.openai_client(), prompt="Please research fakellm")
|
|
64
|
+
assert "found what you were looking for" in result
|
|
65
|
+
assert fakellm.request_count >= 1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The server starts once per session, state is reset before each test, and
|
|
69
|
+
everything is torn down at the end. You never touch a port number or a
|
|
70
|
+
subprocess.
|
|
71
|
+
|
|
72
|
+
## Fixtures
|
|
73
|
+
|
|
74
|
+
| Fixture | What you get |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `fakellm` | A `FakellmServer` handle with fresh conversation state for the test. |
|
|
77
|
+
| `fakellm_openai` | A ready `openai.OpenAI` client pointed at the (reset) server. |
|
|
78
|
+
| `fakellm_anthropic` | A ready `anthropic.Anthropic` client pointed at the (reset) server. |
|
|
79
|
+
|
|
80
|
+
### `FakellmServer` handle
|
|
81
|
+
|
|
82
|
+
- `openai_client(**kwargs)` / `anthropic_client(**kwargs)` — clients pointed at the server.
|
|
83
|
+
- `openai_base_url` / `anthropic_base_url` — raw URLs if you build your own client.
|
|
84
|
+
- `set_config_text(yaml)` — write rules inline and reload.
|
|
85
|
+
- `load_rules(path)` — load rules from a file and reload.
|
|
86
|
+
- `reset()` — clear conversation state (done for you between tests).
|
|
87
|
+
- `reload()` — re-read the config from disk.
|
|
88
|
+
- `stats()` / `conversations()` — the admin JSON, for assertions.
|
|
89
|
+
- `request_count` — convenience count of requests seen.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
Set a starting config file via the command line:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pytest --fakellm-config=tests/fixtures/rules.yaml
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
or in `pyproject.toml` / `pytest.ini`:
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
[tool.pytest.ini_options]
|
|
103
|
+
fakellm_config = "tests/fixtures/rules.yaml"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If you don't set one, a temporary empty config is created so `set_config_text`
|
|
107
|
+
and `load_rules` work immediately.
|
|
108
|
+
|
|
109
|
+
`--fakellm-startup-timeout` (default `10.0`) controls how long the fixture waits
|
|
110
|
+
for the server to come up.
|
|
111
|
+
|
|
112
|
+
## Client extras
|
|
113
|
+
|
|
114
|
+
`openai_client()` and `anthropic_client()` require the respective SDKs. Install
|
|
115
|
+
what you need:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install "pytest-fakellm[openai]" # adds openai
|
|
119
|
+
pip install "pytest-fakellm[anthropic]" # adds anthropic
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pytest-fakellm
|
|
2
|
+
|
|
3
|
+
Pytest fixtures for [fakellm](https://github.com/dgregor/fakellm), the mock
|
|
4
|
+
OpenAI/Anthropic server. Spin up a server, get a clean state per test, and
|
|
5
|
+
assert on what your code sent — with zero boilerplate.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pytest-fakellm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Once installed, the fixtures are available automatically — no imports, no
|
|
12
|
+
`conftest.py` setup.
|
|
13
|
+
|
|
14
|
+
## The point
|
|
15
|
+
|
|
16
|
+
Without the plugin, using fakellm in a test means starting the server, wiring a
|
|
17
|
+
client to its URL, resetting state, and tearing it all down yourself, in every
|
|
18
|
+
test. With the plugin, that becomes:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
def test_agent_handles_search(fakellm):
|
|
22
|
+
fakellm.set_config_text("""
|
|
23
|
+
version: 1
|
|
24
|
+
rules:
|
|
25
|
+
- name: summarize
|
|
26
|
+
when: { messages_contain: "research" }
|
|
27
|
+
respond: { content: "Based on the search, I found what you were looking for." }
|
|
28
|
+
""")
|
|
29
|
+
result = run_my_agent(fakellm.openai_client(), prompt="Please research fakellm")
|
|
30
|
+
assert "found what you were looking for" in result
|
|
31
|
+
assert fakellm.request_count >= 1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The server starts once per session, state is reset before each test, and
|
|
35
|
+
everything is torn down at the end. You never touch a port number or a
|
|
36
|
+
subprocess.
|
|
37
|
+
|
|
38
|
+
## Fixtures
|
|
39
|
+
|
|
40
|
+
| Fixture | What you get |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `fakellm` | A `FakellmServer` handle with fresh conversation state for the test. |
|
|
43
|
+
| `fakellm_openai` | A ready `openai.OpenAI` client pointed at the (reset) server. |
|
|
44
|
+
| `fakellm_anthropic` | A ready `anthropic.Anthropic` client pointed at the (reset) server. |
|
|
45
|
+
|
|
46
|
+
### `FakellmServer` handle
|
|
47
|
+
|
|
48
|
+
- `openai_client(**kwargs)` / `anthropic_client(**kwargs)` — clients pointed at the server.
|
|
49
|
+
- `openai_base_url` / `anthropic_base_url` — raw URLs if you build your own client.
|
|
50
|
+
- `set_config_text(yaml)` — write rules inline and reload.
|
|
51
|
+
- `load_rules(path)` — load rules from a file and reload.
|
|
52
|
+
- `reset()` — clear conversation state (done for you between tests).
|
|
53
|
+
- `reload()` — re-read the config from disk.
|
|
54
|
+
- `stats()` / `conversations()` — the admin JSON, for assertions.
|
|
55
|
+
- `request_count` — convenience count of requests seen.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Set a starting config file via the command line:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pytest --fakellm-config=tests/fixtures/rules.yaml
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
or in `pyproject.toml` / `pytest.ini`:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
fakellm_config = "tests/fixtures/rules.yaml"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If you don't set one, a temporary empty config is created so `set_config_text`
|
|
73
|
+
and `load_rules` work immediately.
|
|
74
|
+
|
|
75
|
+
`--fakellm-startup-timeout` (default `10.0`) controls how long the fixture waits
|
|
76
|
+
for the server to come up.
|
|
77
|
+
|
|
78
|
+
## Client extras
|
|
79
|
+
|
|
80
|
+
`openai_client()` and `anthropic_client()` require the respective SDKs. Install
|
|
81
|
+
what you need:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install "pytest-fakellm[openai]" # adds openai
|
|
85
|
+
pip install "pytest-fakellm[anthropic]" # adds anthropic
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-fakellm"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pytest fixtures for the fakellm mock OpenAI/Anthropic server — spin up, reset, and assert with zero boilerplate."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Douglas Gregor", email = "1dg618@gmail.com" }]
|
|
9
|
+
keywords = ["pytest", "fakellm", "llm", "testing", "mock", "openai", "anthropic", "fixtures"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Framework :: Pytest",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pytest>=7.0",
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"fakellm>=0.3.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
openai = ["openai>=1.40"]
|
|
31
|
+
anthropic = ["anthropic>=0.34"]
|
|
32
|
+
dev = [
|
|
33
|
+
"openai>=1.40",
|
|
34
|
+
"anthropic>=0.34",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/1dg618/pytest-fakellm"
|
|
39
|
+
Repository = "https://github.com/1dg618/pytest-fakellm"
|
|
40
|
+
Issues = "https://github.com/1dg618/pytest-fakellm/issues"
|
|
41
|
+
|
|
42
|
+
# This is the line that makes the whole thing work: pytest discovers installed
|
|
43
|
+
# plugins via the "pytest11" entry-point group. Once this package is installed,
|
|
44
|
+
# the fixtures in pytest_fakellm.plugin are available with no imports.
|
|
45
|
+
[project.entry-points.pytest11]
|
|
46
|
+
fakellm = "pytest_fakellm.plugin"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/pytest_fakellm"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""pytest-fakellm — pytest fixtures for the fakellm mock LLM server.
|
|
2
|
+
|
|
3
|
+
Installing this package makes the fixtures below available in any test, with no
|
|
4
|
+
imports or registration required (pytest auto-discovers the plugin via the
|
|
5
|
+
``pytest11`` entry point declared in pyproject.toml).
|
|
6
|
+
|
|
7
|
+
Typical use::
|
|
8
|
+
|
|
9
|
+
def test_agent(fakellm):
|
|
10
|
+
fakellm.set_config_text(MY_RULES_YAML)
|
|
11
|
+
result = run_my_agent(fakellm.openai_client(), prompt="research fakellm")
|
|
12
|
+
assert "found what you were looking for" in result
|
|
13
|
+
|
|
14
|
+
Fixtures
|
|
15
|
+
--------
|
|
16
|
+
fakellm
|
|
17
|
+
Session-scoped server, automatically reset before each test that uses it.
|
|
18
|
+
fakellm_openai
|
|
19
|
+
Convenience: a ready ``openai.OpenAI`` client pointed at the server.
|
|
20
|
+
fakellm_anthropic
|
|
21
|
+
Convenience: a ready ``anthropic.Anthropic`` client pointed at the server.
|
|
22
|
+
|
|
23
|
+
Configuration
|
|
24
|
+
-------------
|
|
25
|
+
The server's starting config file can be set with the ``--fakellm-config``
|
|
26
|
+
command-line option or the ``fakellm_config`` ini option. If neither is set, a
|
|
27
|
+
temporary empty config is created so that ``set_config_text`` / ``load_rules``
|
|
28
|
+
work out of the box.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Iterator
|
|
35
|
+
|
|
36
|
+
import pytest
|
|
37
|
+
|
|
38
|
+
from .server import FakellmServer
|
|
39
|
+
|
|
40
|
+
__all__ = ["FakellmServer", "fakellm", "fakellm_openai", "fakellm_anthropic"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# -- options ---------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
47
|
+
group = parser.getgroup("fakellm")
|
|
48
|
+
group.addoption(
|
|
49
|
+
"--fakellm-config",
|
|
50
|
+
action="store",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Path to a fakellm YAML config to start the server with.",
|
|
53
|
+
)
|
|
54
|
+
group.addoption(
|
|
55
|
+
"--fakellm-startup-timeout",
|
|
56
|
+
action="store",
|
|
57
|
+
type=float,
|
|
58
|
+
default=10.0,
|
|
59
|
+
help="Seconds to wait for the fakellm server to become ready.",
|
|
60
|
+
)
|
|
61
|
+
parser.addini(
|
|
62
|
+
"fakellm_config",
|
|
63
|
+
help="Path to a fakellm YAML config to start the server with.",
|
|
64
|
+
default=None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_config(request: pytest.FixtureRequest) -> str | None:
|
|
69
|
+
cli = request.config.getoption("--fakellm-config")
|
|
70
|
+
if cli:
|
|
71
|
+
return cli
|
|
72
|
+
ini = request.config.getini("fakellm_config")
|
|
73
|
+
return ini or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# -- fixtures --------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.fixture(scope="session")
|
|
80
|
+
def fakellm_server(
|
|
81
|
+
request: pytest.FixtureRequest,
|
|
82
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
83
|
+
) -> Iterator[FakellmServer]:
|
|
84
|
+
"""Session-scoped, *unreset* server handle.
|
|
85
|
+
|
|
86
|
+
Starts one fakellm process for the whole test session and tears it down at
|
|
87
|
+
the end. Most tests should use the ``fakellm`` fixture instead, which layers
|
|
88
|
+
automatic per-test state reset on top of this.
|
|
89
|
+
"""
|
|
90
|
+
config = _resolve_config(request)
|
|
91
|
+
if config is None:
|
|
92
|
+
# Give the server a writable config file so set_config_text/load_rules
|
|
93
|
+
# have somewhere to write, even when the user didn't supply one.
|
|
94
|
+
config_path = tmp_path_factory.mktemp("fakellm") / "fakellm.yaml"
|
|
95
|
+
config_path.write_text("version: 1\nrules: []\n")
|
|
96
|
+
config = str(config_path)
|
|
97
|
+
|
|
98
|
+
timeout = request.config.getoption("--fakellm-startup-timeout")
|
|
99
|
+
server = FakellmServer(config=config, startup_timeout=timeout)
|
|
100
|
+
server.start()
|
|
101
|
+
try:
|
|
102
|
+
yield server
|
|
103
|
+
finally:
|
|
104
|
+
server.stop()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
def fakellm(fakellm_server: FakellmServer) -> Iterator[FakellmServer]:
|
|
109
|
+
"""The main fixture: a server handle with fresh state for each test.
|
|
110
|
+
|
|
111
|
+
The underlying process is shared across the session (fast), but conversation
|
|
112
|
+
state is cleared before the test body runs, so tests are isolated from one
|
|
113
|
+
another regardless of order.
|
|
114
|
+
"""
|
|
115
|
+
fakellm_server.reset()
|
|
116
|
+
yield fakellm_server
|
|
117
|
+
# No teardown reset needed: the next test resets before it runs. Leaving
|
|
118
|
+
# state intact after a failure can also aid debugging.
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.fixture
|
|
122
|
+
def fakellm_openai(fakellm: FakellmServer) -> Any:
|
|
123
|
+
"""A ready-to-use ``openai.OpenAI`` client pointed at the reset server."""
|
|
124
|
+
return fakellm.openai_client()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.fixture
|
|
128
|
+
def fakellm_anthropic(fakellm: FakellmServer) -> Any:
|
|
129
|
+
"""A ready-to-use ``anthropic.Anthropic`` client pointed at the reset server."""
|
|
130
|
+
return fakellm.anthropic_client()
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Server lifecycle management and the user-facing control handle.
|
|
2
|
+
|
|
3
|
+
This module knows how to launch a ``fakellm`` server as a subprocess, wait for
|
|
4
|
+
it to become healthy, and talk to its documented admin endpoints
|
|
5
|
+
(``/_fakellm/reset``, ``/_fakellm/reload``, ``/_fakellm/stats``). It deliberately
|
|
6
|
+
treats fakellm as a black box driven through its public CLI and HTTP surface,
|
|
7
|
+
so the plugin does not couple to fakellm's internals.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _free_port() -> int:
|
|
25
|
+
"""Ask the OS for an unused TCP port, then release it for fakellm to claim.
|
|
26
|
+
|
|
27
|
+
There is a small race between releasing the port and fakellm binding it,
|
|
28
|
+
but in practice it is negligible for a test fixture and far simpler than
|
|
29
|
+
handing an open socket to a subprocess.
|
|
30
|
+
"""
|
|
31
|
+
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
32
|
+
s.bind(("127.0.0.1", 0))
|
|
33
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
34
|
+
return s.getsockname()[1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FakellmServer:
|
|
38
|
+
"""A running fakellm instance plus helpers for using it from a test.
|
|
39
|
+
|
|
40
|
+
Instances are created by the plugin's fixtures; you normally interact with
|
|
41
|
+
one through the ``fakellm`` fixture rather than constructing it yourself.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
host: Host the server is bound to.
|
|
45
|
+
port: Port the server is bound to.
|
|
46
|
+
base_url: Root URL of the server, e.g. ``http://127.0.0.1:9999``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
config: str | Path | None = None,
|
|
53
|
+
host: str = "127.0.0.1",
|
|
54
|
+
port: int | None = None,
|
|
55
|
+
startup_timeout: float = 10.0,
|
|
56
|
+
executable: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.host = host
|
|
59
|
+
self.port = port or _free_port()
|
|
60
|
+
self._config = Path(config) if config is not None else None
|
|
61
|
+
self._startup_timeout = startup_timeout
|
|
62
|
+
# Launch via the module entry point by default so the plugin works even
|
|
63
|
+
# when the ``fakellm`` console script is not on PATH (e.g. some CI venvs).
|
|
64
|
+
self._executable = executable
|
|
65
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
66
|
+
self._client = httpx.Client(base_url=self.base_url, timeout=10.0)
|
|
67
|
+
|
|
68
|
+
# -- URLs ---------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def base_url(self) -> str:
|
|
72
|
+
return f"http://{self.host}:{self.port}"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def openai_base_url(self) -> str:
|
|
76
|
+
"""Base URL to hand to an OpenAI client (note the ``/v1`` suffix)."""
|
|
77
|
+
return f"{self.base_url}/v1"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def anthropic_base_url(self) -> str:
|
|
81
|
+
"""Base URL to hand to an Anthropic client."""
|
|
82
|
+
return self.base_url
|
|
83
|
+
|
|
84
|
+
# -- lifecycle ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _command(self) -> list[str]:
|
|
87
|
+
if self._executable:
|
|
88
|
+
cmd = [self._executable, "serve"]
|
|
89
|
+
else:
|
|
90
|
+
script = shutil.which("fakellm")
|
|
91
|
+
if script:
|
|
92
|
+
cmd = [script, "serve"]
|
|
93
|
+
else:
|
|
94
|
+
cmd = [sys.executable, "-m", "fakellm", "serve"]
|
|
95
|
+
cmd += ["--port", str(self.port)]
|
|
96
|
+
if self._config is not None:
|
|
97
|
+
cmd += ["--config", str(self._config)]
|
|
98
|
+
return cmd
|
|
99
|
+
|
|
100
|
+
def start(self) -> None:
|
|
101
|
+
"""Launch the server subprocess and block until it is accepting requests."""
|
|
102
|
+
if self._proc is not None:
|
|
103
|
+
return
|
|
104
|
+
self._proc = subprocess.Popen(
|
|
105
|
+
self._command(),
|
|
106
|
+
stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.STDOUT,
|
|
108
|
+
)
|
|
109
|
+
self._wait_until_ready()
|
|
110
|
+
|
|
111
|
+
def _wait_until_ready(self) -> None:
|
|
112
|
+
deadline = time.monotonic() + self._startup_timeout
|
|
113
|
+
last_err: Exception | None = None
|
|
114
|
+
while time.monotonic() < deadline:
|
|
115
|
+
# If the process died during startup, surface its output immediately
|
|
116
|
+
# rather than waiting out the full timeout.
|
|
117
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
118
|
+
output = b""
|
|
119
|
+
if self._proc.stdout is not None:
|
|
120
|
+
output = self._proc.stdout.read()
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"fakellm server exited during startup "
|
|
123
|
+
f"(code {self._proc.returncode}). Command: "
|
|
124
|
+
f"{' '.join(self._command())}\n"
|
|
125
|
+
f"Output:\n{output.decode(errors='replace')}"
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
resp = self._client.get("/_fakellm/stats")
|
|
129
|
+
if resp.status_code < 500:
|
|
130
|
+
return
|
|
131
|
+
except httpx.HTTPError as exc: # not up yet
|
|
132
|
+
last_err = exc
|
|
133
|
+
time.sleep(0.05)
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
f"fakellm server did not become ready within {self._startup_timeout}s "
|
|
136
|
+
f"at {self.base_url}. Last error: {last_err!r}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def stop(self) -> None:
|
|
140
|
+
"""Terminate the server subprocess and close the admin client."""
|
|
141
|
+
self._client.close()
|
|
142
|
+
if self._proc is None:
|
|
143
|
+
return
|
|
144
|
+
self._proc.terminate()
|
|
145
|
+
try:
|
|
146
|
+
self._proc.wait(timeout=5.0)
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
self._proc.kill()
|
|
149
|
+
self._proc.wait(timeout=5.0)
|
|
150
|
+
self._proc = None
|
|
151
|
+
|
|
152
|
+
# -- admin operations ---------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def reset(self) -> None:
|
|
155
|
+
"""Clear all conversation state (POST ``/_fakellm/reset``).
|
|
156
|
+
|
|
157
|
+
Stats and request history are preserved, matching fakellm's documented
|
|
158
|
+
behavior. Called automatically between tests by the ``fakellm`` fixture.
|
|
159
|
+
"""
|
|
160
|
+
self._client.post("/_fakellm/reset").raise_for_status()
|
|
161
|
+
|
|
162
|
+
def reload(self) -> None:
|
|
163
|
+
"""Re-read the YAML config from disk (POST ``/_fakellm/reload``)."""
|
|
164
|
+
self._client.post("/_fakellm/reload").raise_for_status()
|
|
165
|
+
|
|
166
|
+
def load_rules(self, config: str | Path) -> None:
|
|
167
|
+
"""Point the server at a new config file and reload it.
|
|
168
|
+
|
|
169
|
+
This rewrites the path the server was started with by writing the given
|
|
170
|
+
config into place is *not* done here; instead it relies on fakellm's
|
|
171
|
+
``--config`` having been set to a path the test controls. For ad-hoc
|
|
172
|
+
rules use :meth:`set_config_text`.
|
|
173
|
+
"""
|
|
174
|
+
path = Path(config)
|
|
175
|
+
if self._config is None:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
"This server was not started with a config path, so load_rules "
|
|
178
|
+
"cannot point it at a file. Use the `fakellm_config` fixture to "
|
|
179
|
+
"set a config path, or call set_config_text()."
|
|
180
|
+
)
|
|
181
|
+
# Copy the provided file's contents into the active config path.
|
|
182
|
+
Path(self._config).write_text(path.read_text())
|
|
183
|
+
self.reload()
|
|
184
|
+
|
|
185
|
+
def set_config_text(self, yaml_text: str) -> None:
|
|
186
|
+
"""Write raw YAML into the active config file and reload.
|
|
187
|
+
|
|
188
|
+
Requires the server to have been started with a config path (the
|
|
189
|
+
``fakellm_config`` fixture does this for you).
|
|
190
|
+
"""
|
|
191
|
+
if self._config is None:
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
"This server was not started with a config path. Use the "
|
|
194
|
+
"`fakellm_config` fixture (or pass config=...) so there is a "
|
|
195
|
+
"file to write to."
|
|
196
|
+
)
|
|
197
|
+
Path(self._config).write_text(yaml_text)
|
|
198
|
+
self.reload()
|
|
199
|
+
|
|
200
|
+
def stats(self) -> dict[str, Any]:
|
|
201
|
+
"""Return the server's stats JSON (GET ``/_fakellm/stats``)."""
|
|
202
|
+
resp = self._client.get("/_fakellm/stats")
|
|
203
|
+
resp.raise_for_status()
|
|
204
|
+
return resp.json()
|
|
205
|
+
|
|
206
|
+
def conversations(self) -> dict[str, Any]:
|
|
207
|
+
"""Return per-conversation turn/tool-result info (GET ``/_fakellm/conversations``)."""
|
|
208
|
+
resp = self._client.get("/_fakellm/conversations")
|
|
209
|
+
resp.raise_for_status()
|
|
210
|
+
return resp.json()
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def request_count(self) -> int:
|
|
214
|
+
"""Convenience accessor: number of requests seen, from stats."""
|
|
215
|
+
data = self.stats()
|
|
216
|
+
# Be tolerant of the exact stats shape; fall back to len(recent).
|
|
217
|
+
for key in ("request_count", "requests", "total_requests"):
|
|
218
|
+
if isinstance(data.get(key), int):
|
|
219
|
+
return data[key]
|
|
220
|
+
recent = data.get("recent_requests") or data.get("recent") or []
|
|
221
|
+
return len(recent) if isinstance(recent, list) else 0
|
|
222
|
+
|
|
223
|
+
# -- client helpers -----------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def openai_client(self, **kwargs: Any) -> Any:
|
|
226
|
+
"""Return an ``openai.OpenAI`` client pointed at this server.
|
|
227
|
+
|
|
228
|
+
Requires the ``openai`` package to be installed. Extra kwargs are passed
|
|
229
|
+
through to the ``OpenAI(...)`` constructor.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
from openai import OpenAI
|
|
233
|
+
except ImportError as exc: # pragma: no cover - depends on user env
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
"openai is not installed. Install it (e.g. `pip install openai`) "
|
|
236
|
+
"to use fakellm.openai_client()."
|
|
237
|
+
) from exc
|
|
238
|
+
kwargs.setdefault("base_url", self.openai_base_url)
|
|
239
|
+
kwargs.setdefault("api_key", "not-used")
|
|
240
|
+
return OpenAI(**kwargs)
|
|
241
|
+
|
|
242
|
+
def anthropic_client(self, **kwargs: Any) -> Any:
|
|
243
|
+
"""Return an ``anthropic.Anthropic`` client pointed at this server.
|
|
244
|
+
|
|
245
|
+
Requires the ``anthropic`` package to be installed.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
from anthropic import Anthropic
|
|
249
|
+
except ImportError as exc: # pragma: no cover - depends on user env
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
"anthropic is not installed. Install it (e.g. `pip install "
|
|
252
|
+
"anthropic`) to use fakellm.anthropic_client()."
|
|
253
|
+
) from exc
|
|
254
|
+
kwargs.setdefault("base_url", self.anthropic_base_url)
|
|
255
|
+
kwargs.setdefault("api_key", "not-used")
|
|
256
|
+
return Anthropic(**kwargs)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests that use the plugin exactly as an end user would.
|
|
2
|
+
|
|
3
|
+
No imports from pytest_fakellm here on purpose — the `fakellm` fixture must be
|
|
4
|
+
auto-discovered via the entry point, proving the plugin is wired correctly.
|
|
5
|
+
We talk to the server with raw httpx (rather than the openai SDK) so the suite
|
|
6
|
+
needs no extra SDK installs.
|
|
7
|
+
"""
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
RULES = """
|
|
12
|
+
version: 1
|
|
13
|
+
rules:
|
|
14
|
+
- name: summarize
|
|
15
|
+
when: { messages_contain: "research" }
|
|
16
|
+
respond: { content: "I found what you were looking for." }
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _chat(server, text):
|
|
21
|
+
"""Make an OpenAI-style chat call against the server using raw httpx."""
|
|
22
|
+
resp = httpx.post(
|
|
23
|
+
f"{server.openai_base_url}/chat/completions",
|
|
24
|
+
json={"model": "gpt-4", "messages": [{"role": "user", "content": text}]},
|
|
25
|
+
timeout=10.0,
|
|
26
|
+
)
|
|
27
|
+
resp.raise_for_status()
|
|
28
|
+
return resp.json()["choices"][0]["message"]["content"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_fixture_is_autodiscovered(fakellm):
|
|
32
|
+
# If this argument resolves at all, the entry point worked.
|
|
33
|
+
assert fakellm.base_url.startswith("http://127.0.0.1:")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_inline_config_drives_response(fakellm):
|
|
37
|
+
fakellm.set_config_text(RULES)
|
|
38
|
+
content = _chat(fakellm, "please research fakellm")
|
|
39
|
+
assert content == "I found what you were looking for."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_request_count_increments(fakellm):
|
|
43
|
+
start = fakellm.request_count
|
|
44
|
+
_chat(fakellm, "hello")
|
|
45
|
+
_chat(fakellm, "hello again")
|
|
46
|
+
assert fakellm.request_count == start + 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_state_is_reset_between_tests_part1(fakellm):
|
|
50
|
+
# Make some requests; the NEXT test should not see this conversation state.
|
|
51
|
+
_chat(fakellm, "first test traffic")
|
|
52
|
+
convs = fakellm.conversations()
|
|
53
|
+
# Stub tracks conversations dict; just assert the call works and is JSON.
|
|
54
|
+
assert isinstance(convs, dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_state_is_reset_between_tests_part2(fakellm):
|
|
58
|
+
# Because `fakellm` resets before each test, conversation state is empty
|
|
59
|
+
# even though the previous test made requests.
|
|
60
|
+
convs = fakellm.conversations()
|
|
61
|
+
assert convs == {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_raw_base_urls_are_exposed(fakellm):
|
|
65
|
+
assert fakellm.openai_base_url.endswith("/v1")
|
|
66
|
+
assert fakellm.anthropic_base_url == fakellm.base_url
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_default_config_returns_something(fakellm):
|
|
70
|
+
# With no rules set this run, the stub returns its default content.
|
|
71
|
+
fakellm.set_config_text("version: 1\nrules: []\n")
|
|
72
|
+
content = _chat(fakellm, "anything")
|
|
73
|
+
assert isinstance(content, str) and content # got some response
|
|
74
|
+
assert "mock response" in content # it's the fallback echo
|