dj-evals 0.2.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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: dj-evals
3
+ Version: 0.2.0
4
+ Summary: Internal Django SSE eval runner
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: django>=5.0
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=8.0; extra == "test"
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
11
+ Provides-Extra: dev
12
+ Requires-Dist: ruff>=0.8; extra == "dev"
13
+
14
+ # dj-evals
15
+
16
+ Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
17
+
18
+ ## Django setup
19
+
20
+ Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
21
+
22
+ ```python
23
+ INSTALLED_APPS = [
24
+ "dj_evals",
25
+ ]
26
+ ```
27
+
28
+ ```python
29
+ from django.urls import path
30
+
31
+ urlpatterns = [
32
+ path("evals/run/", eval_run),
33
+ ]
34
+ ```
35
+
36
+ Protect the eval view with your app's normal authorization checks before using it in production.
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from dj_evals import handle_eval_request
42
+
43
+ async def eval_run(request):
44
+ return await handle_eval_request(
45
+ request,
46
+ allowed_paths={"myapp.evals.echo_eval"},
47
+ )
48
+ ```
49
+
50
+ Generate a URL for the view with one or more eval argument dictionaries:
51
+
52
+ ```python
53
+ from dj_evals import generate_eval_url
54
+
55
+ url = "/evals/run/" + generate_eval_url(
56
+ "myapp.evals.echo_eval",
57
+ {"model": "gpt-4.1", "text": "hello"},
58
+ {"model": "gemini-3-flash-preview", "text": "hello"},
59
+ )
60
+ ```
61
+
62
+ The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
63
+
64
+ ## Eval function contract
65
+
66
+ The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
67
+
68
+ ```python
69
+ async def echo_eval(text="hello", model=""):
70
+ yield {"type": "response.output_text.delta", "delta": text}
71
+ yield {"type": "response.completed", "response": {"cost": 0}}
72
+ ```
73
+
74
+ Eval argument dictionaries are passed to the eval function as keyword arguments.
75
+
76
+ ## Developers
77
+
78
+ Run a local demo server to try the library in a browser:
79
+
80
+ ```bash
81
+ uv run --with daphne daphne examples.local_server:application
82
+ # or, if you have just installed:
83
+ just run
84
+ ```
85
+
86
+ Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
87
+
88
+ Run checks from this repo:
89
+
90
+ ```bash
91
+ uv run --with ruff ruff check .
92
+ uv run --with pytest --with pytest-asyncio pytest -q
93
+ # or, if you have just installed:
94
+ just test
95
+ ```
@@ -0,0 +1,82 @@
1
+ # dj-evals
2
+
3
+ Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
4
+
5
+ ## Django setup
6
+
7
+ Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
8
+
9
+ ```python
10
+ INSTALLED_APPS = [
11
+ "dj_evals",
12
+ ]
13
+ ```
14
+
15
+ ```python
16
+ from django.urls import path
17
+
18
+ urlpatterns = [
19
+ path("evals/run/", eval_run),
20
+ ]
21
+ ```
22
+
23
+ Protect the eval view with your app's normal authorization checks before using it in production.
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from dj_evals import handle_eval_request
29
+
30
+ async def eval_run(request):
31
+ return await handle_eval_request(
32
+ request,
33
+ allowed_paths={"myapp.evals.echo_eval"},
34
+ )
35
+ ```
36
+
37
+ Generate a URL for the view with one or more eval argument dictionaries:
38
+
39
+ ```python
40
+ from dj_evals import generate_eval_url
41
+
42
+ url = "/evals/run/" + generate_eval_url(
43
+ "myapp.evals.echo_eval",
44
+ {"model": "gpt-4.1", "text": "hello"},
45
+ {"model": "gemini-3-flash-preview", "text": "hello"},
46
+ )
47
+ ```
48
+
49
+ The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
50
+
51
+ ## Eval function contract
52
+
53
+ The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
54
+
55
+ ```python
56
+ async def echo_eval(text="hello", model=""):
57
+ yield {"type": "response.output_text.delta", "delta": text}
58
+ yield {"type": "response.completed", "response": {"cost": 0}}
59
+ ```
60
+
61
+ Eval argument dictionaries are passed to the eval function as keyword arguments.
62
+
63
+ ## Developers
64
+
65
+ Run a local demo server to try the library in a browser:
66
+
67
+ ```bash
68
+ uv run --with daphne daphne examples.local_server:application
69
+ # or, if you have just installed:
70
+ just run
71
+ ```
72
+
73
+ Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
74
+
75
+ Run checks from this repo:
76
+
77
+ ```bash
78
+ uv run --with ruff ruff check .
79
+ uv run --with pytest --with pytest-asyncio pytest -q
80
+ # or, if you have just installed:
81
+ just test
82
+ ```
@@ -0,0 +1,6 @@
1
+ from .views import generate_eval_url, handle_eval_request
2
+
3
+ __all__ = [
4
+ "generate_eval_url",
5
+ "handle_eval_request",
6
+ ]
@@ -0,0 +1,55 @@
1
+ import importlib
2
+ import inspect
3
+ import json
4
+ import traceback
5
+
6
+
7
+ async def iter_eval_events(*, eval_path, kwargs):
8
+ """Yield eval events as JSON-safe values for SSE publishing.
9
+
10
+ The eval function is imported from ``eval_path``, called with ``kwargs``, and
11
+ consumed as an async iterator. Events are kept in their original shape when
12
+ possible so the browser can render them.
13
+ """
14
+ try:
15
+ eval_func = _import_from_dotted_path(eval_path)
16
+ async for event in eval_func(**kwargs):
17
+ yield _json_safe_event(event)
18
+ except Exception as exc:
19
+ yield {"type": "error", "message": f"{exc}\n{traceback.format_exc()}"}
20
+
21
+
22
+ def _event_to_dict(event):
23
+ """Return SDK model events as plain dictionaries when possible."""
24
+ if isinstance(event, dict):
25
+ return event
26
+ if not hasattr(event, "to_dict"):
27
+ return event
28
+
29
+ try:
30
+ return event.to_dict(mode="json", warnings=False)
31
+ except TypeError:
32
+ return event.to_dict()
33
+
34
+
35
+ def _json_safe_event(event):
36
+ event = _event_to_dict(event)
37
+ try:
38
+ json.dumps(event)
39
+ except TypeError:
40
+ return str(event)
41
+ return event
42
+
43
+
44
+ def _import_from_dotted_path(eval_path):
45
+ """Import and validate an async-generator eval function by dotted path."""
46
+ module_path, _, function_name = eval_path.rpartition(".")
47
+ if not module_path or not function_name:
48
+ raise ImportError(f"Invalid eval_path: {eval_path}")
49
+ module = importlib.import_module(module_path)
50
+ eval_func = getattr(module, function_name)
51
+ if not callable(eval_func):
52
+ raise TypeError(f"{eval_path} is not callable")
53
+ if not inspect.isasyncgenfunction(eval_func):
54
+ raise TypeError(f"{eval_path} is not an async generator function")
55
+ return eval_func
@@ -0,0 +1,180 @@
1
+ (function () {
2
+ function stringify(value) {
3
+ if (typeof value === "string") {
4
+ return value;
5
+ }
6
+ try {
7
+ return JSON.stringify(value, null, 2);
8
+ } catch (error) {
9
+ return String(value);
10
+ }
11
+ }
12
+
13
+ function append(outputEl, text) {
14
+ outputEl.append(document.createTextNode(String(text)));
15
+ }
16
+
17
+ function appendEvent(outputEl, event) {
18
+ append(outputEl, "\n" + stringify(event) + "\n");
19
+ }
20
+
21
+ function appendEmptyLine(outputEl) {
22
+ append(outputEl, "\n");
23
+ }
24
+
25
+ function appendEvalEvent(outputEl, event) {
26
+ if (typeof event === "string") {
27
+ append(outputEl, event);
28
+ return;
29
+ }
30
+
31
+ if (!event || typeof event !== "object") {
32
+ appendEvent(outputEl, event);
33
+ return;
34
+ }
35
+
36
+ if (event.type === "response.output_text.delta") {
37
+ append(outputEl, event.delta || event.text || "");
38
+ return;
39
+ }
40
+
41
+ if (["response.completed", "response.output_text.done"].includes(event.type)) {
42
+ appendEmptyLine(outputEl);
43
+ return;
44
+ }
45
+
46
+ if (event.type === "error") {
47
+ append(outputEl, "\n" + (event.message || "Unknown error") + "\n");
48
+ return;
49
+ }
50
+
51
+ if (event.choices) {
52
+ event.choices.forEach(function (choice) {
53
+ append(outputEl, (choice.delta || {}).content || "");
54
+ });
55
+ return;
56
+ }
57
+
58
+ if (event.content) {
59
+ append(outputEl, event.content);
60
+ return;
61
+ }
62
+
63
+ appendEvent(outputEl, event);
64
+ }
65
+
66
+ function readArguments(form) {
67
+ var kwargs = {};
68
+ new FormData(form).forEach(function (value, key) {
69
+ kwargs[key] = value;
70
+ });
71
+ return kwargs;
72
+ }
73
+
74
+ function getCsrfToken() {
75
+ return document.querySelector('meta[name="csrf-token"]').content;
76
+ }
77
+
78
+ function parseSseBlock(block) {
79
+ var eventType = "message";
80
+ var data = [];
81
+ block.replace(/\r/g, "").split("\n").forEach(function (line) {
82
+ if (line.indexOf("event:") === 0) {
83
+ eventType = line.slice(6).trim();
84
+ }
85
+ if (line.indexOf("data:") === 0) {
86
+ data.push(line.slice(5).trimStart());
87
+ }
88
+ });
89
+ return {type: eventType, data: data.join("\n")};
90
+ }
91
+
92
+ function readSse(text, outputEl, state) {
93
+ var blocks = (state.pendingSseText + text).split("\n\n");
94
+ state.pendingSseText = blocks.pop();
95
+
96
+ blocks.forEach(function (block) {
97
+ var message = parseSseBlock(block);
98
+ if (message.type === "done") {
99
+ appendEmptyLine(outputEl);
100
+ return;
101
+ }
102
+
103
+ // Keep-alive or malformed SSE blocks may have no data.
104
+ if (!message.data) {
105
+ return;
106
+ }
107
+
108
+ if (message.type === "eval-event") {
109
+ appendEvalEvent(outputEl, JSON.parse(message.data).event);
110
+ return;
111
+ }
112
+
113
+ appendEvent(outputEl, message);
114
+ });
115
+ }
116
+
117
+ async function startRun(section) {
118
+ // The user can re-run a panel while the previous stream is active.
119
+ if (section.djEvalsAbortController) {
120
+ section.djEvalsAbortController.abort();
121
+ }
122
+
123
+ var outputEl = section.querySelector(".events");
124
+ var form = section.querySelector(".eval-args");
125
+ var startUrl = new URL(section.dataset.startUrl, window.location.href);
126
+ var state = {
127
+ // Stream chunks can split one SSE message, so keep the unfinished tail.
128
+ pendingSseText: ""
129
+ };
130
+ var abortController = new AbortController();
131
+ section.djEvalsAbortController = abortController;
132
+ startUrl.searchParams.set("eval_args", JSON.stringify(readArguments(form)));
133
+ outputEl.textContent = "";
134
+
135
+ try {
136
+ var response = await fetch(startUrl.toString(), {
137
+ method: "POST",
138
+ credentials: "same-origin",
139
+ headers: {"X-CSRFToken": getCsrfToken()},
140
+ signal: abortController.signal
141
+ });
142
+
143
+ // Validation and permission errors return normal HTTP failures.
144
+ if (!response.ok) {
145
+ append(outputEl, "Eval request failed: " + response.status + "\n");
146
+ return;
147
+ }
148
+
149
+ var reader = response.body.getReader();
150
+ var decoder = new TextDecoder();
151
+ while (true) {
152
+ var result = await reader.read();
153
+ if (result.done) {
154
+ readSse(decoder.decode(), outputEl, state);
155
+ return;
156
+ }
157
+ readSse(decoder.decode(result.value, {stream: true}), outputEl, state);
158
+ }
159
+ } catch (error) {
160
+ // Re-running a panel aborts the previous request intentionally.
161
+ if (error.name === "AbortError") {
162
+ return;
163
+ }
164
+ append(outputEl, String(error) + "\n");
165
+ }
166
+ }
167
+
168
+ function main() {
169
+ document.querySelectorAll("[data-eval-run]").forEach(function (section) {
170
+ var form = section.querySelector(".eval-args");
171
+ form.addEventListener("submit", function (event) {
172
+ event.preventDefault();
173
+ startRun(section);
174
+ });
175
+ startRun(section);
176
+ });
177
+ }
178
+
179
+ main();
180
+ }());
@@ -0,0 +1,53 @@
1
+ {% load static %}
2
+ <!doctype html>
3
+ <html>
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="csrf-token" content="{{ csrf_token }}">
7
+ <title>Eval run</title>
8
+ <style>
9
+ html, body { max-width: 100%; overflow-x: hidden; }
10
+ body { font-family: system-ui, sans-serif; margin: 24px; }
11
+ .runs {
12
+ display: flex;
13
+ flex-wrap: wrap;
14
+ gap: 16px;
15
+ }
16
+ section {
17
+ flex: 1 1 calc((100% - 32px) / 3);
18
+ min-width: 0;
19
+ border: 1px solid #ddd;
20
+ box-sizing: border-box;
21
+ padding: 8px;
22
+ }
23
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
24
+ h1 { overflow-wrap: anywhere; }
25
+ label { display: block; margin: 8px 0; }
26
+ label span { display: block; font-weight: 600; margin-bottom: 4px; }
27
+ input { box-sizing: border-box; width: 100%; }
28
+ button { margin: 8px 0 16px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <main>
33
+ <h1>{{ eval_path }}</h1>
34
+ <div class="runs">
35
+ {% for run in runs %}
36
+ <section data-eval-run data-start-url="{{ run.start_url }}">
37
+ <form class="eval-args">
38
+ {% for key, value in run.arguments %}
39
+ <label>
40
+ <span>{{ key }}</span>
41
+ <input name="{{ key }}" value="{{ value }}">
42
+ </label>
43
+ {% endfor %}
44
+ <button type="submit">Run eval</button>
45
+ </form>
46
+ <pre class="events"></pre>
47
+ </section>
48
+ {% endfor %}
49
+ </div>
50
+ </main>
51
+ <script src="{% static 'dj_evals/run_page.js' %}"></script>
52
+ </body>
53
+ </html>
@@ -0,0 +1,142 @@
1
+ import json
2
+ from collections.abc import Collection
3
+ from urllib.parse import urlencode
4
+
5
+ from django.http import (
6
+ HttpResponseBadRequest,
7
+ HttpResponseForbidden,
8
+ HttpResponseNotAllowed,
9
+ StreamingHttpResponse,
10
+ )
11
+ from django.middleware.csrf import get_token
12
+ from django.shortcuts import render
13
+
14
+ from .events import iter_eval_events
15
+
16
+ EVAL_PARAM = "eval_path"
17
+ ARGS_PARAM = "eval_args"
18
+
19
+
20
+ def generate_eval_url(eval_path: str, *args: dict) -> str:
21
+ """Generate a query-string URL for one eval path and argument sets."""
22
+ pairs = [(EVAL_PARAM, eval_path)]
23
+ for kwargs in args:
24
+ # Callers may pass malformed arguments; only dict kwargs can be encoded.
25
+ if not isinstance(kwargs, dict):
26
+ raise TypeError("eval arguments must be a dictionary")
27
+ pairs.append((
28
+ ARGS_PARAM,
29
+ json.dumps(kwargs, sort_keys=True, separators=(",", ":")),
30
+ ))
31
+ return "?" + urlencode(pairs)
32
+
33
+
34
+ async def handle_eval_request(request, *, allowed_paths: Collection[str]):
35
+ """Render the eval runner page on GET or publish one eval run on POST.
36
+
37
+ Use this from a host Django view::
38
+
39
+ return await handle_eval_request(
40
+ request,
41
+ allowed_paths={"myapp.evals.some_eval"},
42
+ )
43
+
44
+ The request should include an ``eval_path`` query parameter. It must match
45
+ one of the dotted paths in ``allowed_paths``. Use ``generate_eval_url`` to
46
+ create URLs.
47
+
48
+ All eval arguments are passed to the eval function as keyword arguments.
49
+ Multiple argument sets create separate SSE-backed panels.
50
+
51
+ GET renders an HTML page. Browser JavaScript starts each eval run with a
52
+ POST request, and that POST response streams SSE events directly.
53
+
54
+ Args:
55
+ request: Django request with the eval query params.
56
+ allowed_paths: Whitelist of eval dotted paths that can be run.
57
+
58
+ Returns:
59
+ ``HttpResponse`` for the outer page or run trigger.
60
+ """
61
+ if request.method == "GET":
62
+ return _render_eval_page(request, allowed_paths)
63
+ if request.method == "POST":
64
+ return await _run_eval(request, allowed_paths)
65
+ return HttpResponseNotAllowed(["GET", "POST"])
66
+
67
+
68
+ def _render_eval_page(request, allowed_paths: Collection[str]):
69
+ try:
70
+ eval_path, eval_arguments = _parse_eval_request(request, allowed_paths)
71
+ except PermissionError as exc:
72
+ return HttpResponseForbidden(str(exc))
73
+ except (TypeError, ValueError) as exc:
74
+ return HttpResponseBadRequest(str(exc))
75
+
76
+ runs = []
77
+ for kwargs in eval_arguments:
78
+ query = generate_eval_url(eval_path, kwargs)
79
+ runs.append({
80
+ "arguments": kwargs.items(),
81
+ "start_url": request.path + query,
82
+ })
83
+
84
+ return render(request, "dj_evals/run_page.html", {
85
+ "csrf_token": get_token(request),
86
+ "eval_path": eval_path,
87
+ "runs": runs,
88
+ })
89
+
90
+
91
+ async def _run_eval(request, allowed_paths: Collection[str]):
92
+ try:
93
+ eval_path, eval_arguments = _parse_eval_request(request, allowed_paths)
94
+ except PermissionError as exc:
95
+ return HttpResponseForbidden(str(exc))
96
+ except (TypeError, ValueError) as exc:
97
+ return HttpResponseBadRequest(str(exc))
98
+
99
+ async def stream_events():
100
+ async for event in iter_eval_events(
101
+ eval_path=eval_path,
102
+ kwargs=eval_arguments[0],
103
+ ):
104
+ payload = json.dumps({"event": event}, separators=(",", ":"))
105
+ yield f"event: eval-event\ndata: {payload}\n\n"
106
+
107
+ yield "event: done\ndata: {}\n\n"
108
+
109
+ response = StreamingHttpResponse(
110
+ stream_events(),
111
+ content_type="text/event-stream",
112
+ )
113
+ response["Cache-Control"] = "no-cache"
114
+ response["X-Accel-Buffering"] = "no"
115
+ return response
116
+
117
+
118
+ def _parse_eval_request(
119
+ request,
120
+ allowed_paths: Collection[str],
121
+ ) -> tuple[str, list[dict]]:
122
+ eval_path = request.GET.get(EVAL_PARAM, "")
123
+ # Reject missing or unapproved eval paths before importing host code.
124
+ if eval_path not in allowed_paths:
125
+ raise PermissionError("eval_path is not allowed")
126
+
127
+ eval_arguments = []
128
+ for value in request.GET.getlist(ARGS_PARAM):
129
+ try:
130
+ kwargs = json.loads(value)
131
+ except json.JSONDecodeError as exc:
132
+ raise ValueError("eval_args must be valid JSON") from exc
133
+
134
+ # Users can edit eval_args by hand, so validate before use.
135
+ if not isinstance(kwargs, dict):
136
+ raise TypeError("eval arguments must be a dictionary")
137
+ eval_arguments.append(kwargs)
138
+
139
+ # No eval_args means run the eval once with no kwargs.
140
+ if not eval_arguments:
141
+ eval_arguments.append({})
142
+ return eval_path, eval_arguments
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: dj-evals
3
+ Version: 0.2.0
4
+ Summary: Internal Django SSE eval runner
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: django>=5.0
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=8.0; extra == "test"
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
11
+ Provides-Extra: dev
12
+ Requires-Dist: ruff>=0.8; extra == "dev"
13
+
14
+ # dj-evals
15
+
16
+ Internal Django helper for running one eval function with multiple argument sets in parallel, so you can visually compare output, tool calls, usage, cost, and completion behavior as the runs happen.
17
+
18
+ ## Django setup
19
+
20
+ Add `dj_evals` to `INSTALLED_APPS` and mount your eval view:
21
+
22
+ ```python
23
+ INSTALLED_APPS = [
24
+ "dj_evals",
25
+ ]
26
+ ```
27
+
28
+ ```python
29
+ from django.urls import path
30
+
31
+ urlpatterns = [
32
+ path("evals/run/", eval_run),
33
+ ]
34
+ ```
35
+
36
+ Protect the eval view with your app's normal authorization checks before using it in production.
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from dj_evals import handle_eval_request
42
+
43
+ async def eval_run(request):
44
+ return await handle_eval_request(
45
+ request,
46
+ allowed_paths={"myapp.evals.echo_eval"},
47
+ )
48
+ ```
49
+
50
+ Generate a URL for the view with one or more eval argument dictionaries:
51
+
52
+ ```python
53
+ from dj_evals import generate_eval_url
54
+
55
+ url = "/evals/run/" + generate_eval_url(
56
+ "myapp.evals.echo_eval",
57
+ {"model": "gpt-4.1", "text": "hello"},
58
+ {"model": "gemini-3-flash-preview", "text": "hello"},
59
+ )
60
+ ```
61
+
62
+ The GET request renders the comparison page. The page starts one POST request per argument set. Each POST response is a direct SSE stream from the eval run, and the browser renders those events into the comparison panels.
63
+
64
+ ## Eval function contract
65
+
66
+ The eval function must be importable by dotted path, whitelisted in `allowed_paths`, and must be an async generator.
67
+
68
+ ```python
69
+ async def echo_eval(text="hello", model=""):
70
+ yield {"type": "response.output_text.delta", "delta": text}
71
+ yield {"type": "response.completed", "response": {"cost": 0}}
72
+ ```
73
+
74
+ Eval argument dictionaries are passed to the eval function as keyword arguments.
75
+
76
+ ## Developers
77
+
78
+ Run a local demo server to try the library in a browser:
79
+
80
+ ```bash
81
+ uv run --with daphne daphne examples.local_server:application
82
+ # or, if you have just installed:
83
+ just run
84
+ ```
85
+
86
+ Then open <http://127.0.0.1:8000/>. The demo redirects to an eval URL that starts three argument sets side by side and streams their events over SSE.
87
+
88
+ Run checks from this repo:
89
+
90
+ ```bash
91
+ uv run --with ruff ruff check .
92
+ uv run --with pytest --with pytest-asyncio pytest -q
93
+ # or, if you have just installed:
94
+ just test
95
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ dj_evals/__init__.py
4
+ dj_evals/events.py
5
+ dj_evals/views.py
6
+ dj_evals.egg-info/PKG-INFO
7
+ dj_evals.egg-info/SOURCES.txt
8
+ dj_evals.egg-info/dependency_links.txt
9
+ dj_evals.egg-info/requires.txt
10
+ dj_evals.egg-info/top_level.txt
11
+ dj_evals/static/dj_evals/run_page.js
12
+ dj_evals/templates/dj_evals/run_page.html
13
+ tests/test_events.py
14
+ tests/test_views.py
@@ -0,0 +1,8 @@
1
+ django>=5.0
2
+
3
+ [dev]
4
+ ruff>=0.8
5
+
6
+ [test]
7
+ pytest>=8.0
8
+ pytest-asyncio>=0.23
@@ -0,0 +1 @@
1
+ dj_evals
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "dj-evals"
3
+ version = "0.2.0"
4
+ description = "Internal Django SSE eval runner"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "django>=5.0",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ test = [
13
+ "pytest>=8.0",
14
+ "pytest-asyncio>=0.23",
15
+ ]
16
+ dev = [
17
+ "ruff>=0.8",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["dj_evals*"]
22
+
23
+
24
+ [tool.setuptools.package-data]
25
+ dj_evals = ["templates/dj_evals/*.html", "static/dj_evals/*.js"]
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=69", "wheel"]
29
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ import pytest
2
+
3
+ from dj_evals.events import iter_eval_events
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_iter_eval_events_yields_raw_eval_events():
8
+ events = [
9
+ event
10
+ async for event in iter_eval_events(
11
+ eval_path="tests.fake_evals.echo_eval",
12
+ kwargs={"text": "from url", "model": "model-a"},
13
+ )
14
+ ]
15
+
16
+ assert events == [
17
+ {"type": "response.output_text.delta", "delta": "model-a:from url"},
18
+ {
19
+ "type": "response.completed",
20
+ "response": {
21
+ "usage": {"input_tokens": 3, "output_tokens": 4},
22
+ "cost": 0.01,
23
+ },
24
+ },
25
+ ]
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_iter_eval_events_yields_failures_as_error_events():
30
+ events = [
31
+ event
32
+ async for event in iter_eval_events(
33
+ eval_path="tests.fake_evals.missing_eval",
34
+ kwargs={},
35
+ )
36
+ ]
37
+
38
+ assert events[0]["type"] == "error"
39
+ assert "missing_eval" in events[0]["message"]
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_iter_eval_events_serializes_sdk_events_without_pydantic_warnings():
44
+ events = [
45
+ event
46
+ async for event in iter_eval_events(
47
+ eval_path="tests.fake_evals.sdk_eval",
48
+ kwargs={},
49
+ )
50
+ ]
51
+
52
+ assert events == [
53
+ {"type": "response.output_text.delta", "delta": "quiet sdk event"}
54
+ ]
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_iter_eval_events_stringifies_non_json_events():
59
+ events = [
60
+ event
61
+ async for event in iter_eval_events(
62
+ eval_path="tests.fake_evals.object_eval",
63
+ kwargs={},
64
+ )
65
+ ]
66
+
67
+ assert events[0].startswith("<object object at")
@@ -0,0 +1,160 @@
1
+ import pytest
2
+ from django.test import RequestFactory
3
+
4
+ from dj_evals import generate_eval_url, handle_eval_request
5
+
6
+
7
+ def test_handle_eval_request_is_importable():
8
+ assert callable(handle_eval_request)
9
+
10
+
11
+ def test_handle_eval_request_requires_named_allowed_paths():
12
+ request = RequestFactory().get(
13
+ "/evals/run/",
14
+ {"eval_path": "tests.fake_evals.echo_eval"},
15
+ )
16
+
17
+ with pytest.raises(TypeError):
18
+ handle_eval_request(request, {"tests.fake_evals.echo_eval"})
19
+
20
+
21
+ def test_generate_eval_url_encodes_eval_arguments():
22
+ url = generate_eval_url(
23
+ "tests.fake_evals.echo_eval",
24
+ {"model": "gpt-4.1", "temperature": 0.2},
25
+ )
26
+
27
+ assert "eval_path=tests.fake_evals.echo_eval" in url
28
+ assert "eval_args=" in url
29
+ assert "temperature" in url
30
+
31
+
32
+ def test_generate_eval_url_rejects_non_dict_arguments():
33
+ with pytest.raises(TypeError, match="dictionary"):
34
+ generate_eval_url("tests.fake_evals.echo_eval", ["not", "a", "dict"])
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_handle_eval_request_renders_sse_panels_for_each_argument_set():
39
+ request = RequestFactory().get(
40
+ "/evals/run/"
41
+ + generate_eval_url(
42
+ "tests.fake_evals.echo_eval",
43
+ {"model": "gpt-4.1", "text": "hello"},
44
+ {"model": "openrouter/openai/gpt-4.1", "text": "hello"},
45
+ ),
46
+ )
47
+
48
+ response = await handle_eval_request(
49
+ request,
50
+ allowed_paths={"tests.fake_evals.echo_eval"},
51
+ )
52
+ html = response.content.decode()
53
+
54
+ assert response.status_code == 200
55
+ assert html.count("<section data-eval-run") == 2
56
+ assert '<script src="/static/dj_evals/run_page.js"></script>' in html
57
+ assert '<meta name="csrf-token"' in html
58
+ assert "/events/" not in html
59
+ assert "data-channel" not in html
60
+ assert "<h1>tests.fake_evals.echo_eval</h1>" in html
61
+ assert 'input name="model" value="gpt-4.1"' in html
62
+ assert 'input name="text" value="hello"' in html
63
+ assert 'input name="model" value="openrouter/openai/gpt-4.1"' in html
64
+ assert '<button type="submit">Run eval</button>' in html
65
+ assert "overflow-x: hidden" in html
66
+ assert "flex-wrap: wrap" in html
67
+ assert "calc((100% - 32px) / 3)" in html
68
+ assert "<iframe" not in html
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_handle_eval_request_defaults_to_one_empty_argument_set():
73
+ request = RequestFactory().get(
74
+ "/evals/run/" + generate_eval_url("tests.fake_evals.echo_eval"),
75
+ )
76
+
77
+ response = await handle_eval_request(
78
+ request,
79
+ allowed_paths={"tests.fake_evals.echo_eval"},
80
+ )
81
+ html = response.content.decode()
82
+
83
+ assert response.status_code == 200
84
+ assert html.count("<section data-eval-run") == 1
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_handle_eval_request_post_streams_eval_events_as_sse():
89
+ request = RequestFactory().post(
90
+ "/evals/run/"
91
+ + generate_eval_url(
92
+ "tests.fake_evals.echo_eval",
93
+ {"model": "gpt-4.1", "text": "hello"},
94
+ ),
95
+ )
96
+
97
+ response = await handle_eval_request(
98
+ request,
99
+ allowed_paths={"tests.fake_evals.echo_eval"},
100
+ )
101
+
102
+ chunks = []
103
+ async for chunk in response.streaming_content:
104
+ chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
105
+ body = "".join(chunks)
106
+
107
+ assert response.status_code == 200
108
+ assert response["Content-Type"] == "text/event-stream"
109
+ assert "event: eval-event" in body
110
+ assert "gpt-4.1:hello" in body
111
+ assert "event: done" in body
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_handle_eval_request_rejects_disallowed_eval_path():
116
+ request = RequestFactory().get(
117
+ "/evals/run/",
118
+ {"eval_path": "tests.fake_evals.echo_eval"},
119
+ )
120
+
121
+ response = await handle_eval_request(
122
+ request,
123
+ allowed_paths={"tests.fake_evals.other_eval"},
124
+ )
125
+
126
+ assert response.status_code == 403
127
+ assert b"eval_path is not allowed" in response.content
128
+
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_handle_eval_request_rejects_disallowed_post_eval_path():
132
+ request = RequestFactory().post(
133
+ "/evals/run/"
134
+ + generate_eval_url(
135
+ "tests.fake_evals.echo_eval",
136
+ {},
137
+ ),
138
+ )
139
+
140
+ response = await handle_eval_request(
141
+ request,
142
+ allowed_paths={"tests.fake_evals.other_eval"},
143
+ )
144
+
145
+ assert response.status_code == 403
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_handle_eval_request_rejects_unsupported_methods():
150
+ request = RequestFactory().put(
151
+ "/evals/run/",
152
+ QUERY_STRING="eval_path=tests.fake_evals.echo_eval",
153
+ )
154
+
155
+ response = await handle_eval_request(
156
+ request,
157
+ allowed_paths={"tests.fake_evals.echo_eval"},
158
+ )
159
+
160
+ assert response.status_code == 405