driftless 0.2.7__tar.gz → 0.2.9__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.
- {driftless-0.2.7 → driftless-0.2.9}/CHANGELOG.md +24 -3
- {driftless-0.2.7 → driftless-0.2.9}/PKG-INFO +2 -2
- {driftless-0.2.7 → driftless-0.2.9}/README.md +1 -1
- {driftless-0.2.7 → driftless-0.2.9}/docs/RELEASE.md +4 -4
- {driftless-0.2.7 → driftless-0.2.9}/site/docs.html +1 -1
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/__init__.py +1 -1
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/contract.py +10 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/harness.py +80 -41
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_endpoint.py +45 -0
- driftless-0.2.9/tests/test_fetch_provider_models.py +111 -0
- {driftless-0.2.7 → driftless-0.2.9}/.gitignore +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/LICENSE +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/docs/repair-and-generators.md +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/pyproject.toml +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/app.js +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/hero-workflow.png +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/landing.css +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/runs.css +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/runs.js +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/sample-run.json +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/assets/styles.css +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/index.html +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/site/runs.html +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/calibrate.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/cli.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/compare.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/configure.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/data/model_lifecycle.json +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/datasource.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/datastate.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/discovery.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/engine.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/errors.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/evaluation.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/generators.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/github.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/init_ci.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/judges.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/label_audit.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/lifecycle.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/policy.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/preflight.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/progress.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/report.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/scanner.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/splits.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/templates.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/src/driftless/view.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/live_eval_baseline.json +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/driftless.yml +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/inputs.jsonl +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/labels.jsonl +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/regression_metrics.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/scenarios.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_cli.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_compare.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_contract.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_data_change_gate.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_data_change_regression.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_datasource.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_datastate.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_discovery.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_engine.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_evaluation.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_extraction.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_generators.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_github.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_grading_loop.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_harness.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_init_ci.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_judge.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_judge_loop.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_label_audit.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_lifecycle.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_migration_live.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_migration_regression.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_plan_act.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_policy.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_poll_act.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_preflight.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_progress.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_refine.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_refresh_catalog.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_regression_metrics.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_repair_prompt.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_report.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_scanner.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_splits.py +0 -0
- {driftless-0.2.7 → driftless-0.2.9}/tests/test_view.py +0 -0
|
@@ -17,6 +17,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
17
|
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
+
## [0.2.9] - 2026-07-01
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **P5.2 endpoint concurrency** — optional `run.endpoint_concurrency` (1–32,
|
|
25
|
+
default 1) runs endpoint POSTs in parallel via `ThreadPoolExecutor`; output
|
|
26
|
+
line order always matches the input file.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [0.2.8] - 2026-07-01
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **P1.1 provider model discovery** — `tools/fetch_provider_models.py` queries
|
|
35
|
+
OpenAI and Anthropic `/models` APIs and emits new catalog entries only (never
|
|
36
|
+
overwrites lifecycle on existing ids). The scheduled `refresh-catalog.yml`
|
|
37
|
+
job merges discoveries when API keys are configured.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
20
41
|
## [0.2.7] - 2026-07-01
|
|
21
42
|
|
|
22
43
|
### Added
|
|
@@ -153,9 +174,9 @@ First public release on [PyPI](https://pypi.org/project/driftless/0.1.0/).
|
|
|
153
174
|
- **Docs** — project overview, repair algorithm spec, 2×2 migration methodology,
|
|
154
175
|
Poetry + Dependabot product framing.
|
|
155
176
|
|
|
156
|
-
[Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.
|
|
157
|
-
[0.2.
|
|
158
|
-
[0.2.
|
|
177
|
+
[Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.9...HEAD
|
|
178
|
+
[0.2.9]: https://github.com/driftless-dev/driftless/releases/tag/v0.2.9
|
|
179
|
+
[0.2.8]: https://github.com/driftless-dev/driftless/compare/v0.2.8...v0.2.9
|
|
159
180
|
[0.2.4]: https://github.com/driftless-dev/driftless/compare/v0.2.4...v0.2.5
|
|
160
181
|
[0.2.3]: https://github.com/driftless-dev/driftless/compare/v0.2.3...v0.2.4
|
|
161
182
|
[0.2.2]: https://github.com/driftless-dev/driftless/compare/v0.2.2...v0.2.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: driftless
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Keep prompts in sync when model or eval data changes — Poetry-style lock regeneration, Dependabot-style PRs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/driftless-dev/driftless
|
|
6
6
|
Project-URL: Repository, https://github.com/driftless-dev/driftless
|
|
@@ -133,7 +133,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
|
|
|
133
133
|
`plan --act` triage, and manually-triggered migration workflows.
|
|
134
134
|
|
|
135
135
|
```yaml
|
|
136
|
-
- uses: driftless-dev/driftless@v0.2.
|
|
136
|
+
- uses: driftless-dev/driftless@v0.2.9
|
|
137
137
|
with:
|
|
138
138
|
command: scan
|
|
139
139
|
```
|
|
@@ -94,7 +94,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
|
|
|
94
94
|
`plan --act` triage, and manually-triggered migration workflows.
|
|
95
95
|
|
|
96
96
|
```yaml
|
|
97
|
-
- uses: driftless-dev/driftless@v0.2.
|
|
97
|
+
- uses: driftless-dev/driftless@v0.2.9
|
|
98
98
|
with:
|
|
99
99
|
command: scan
|
|
100
100
|
```
|
|
@@ -153,7 +153,7 @@ After a release, users can pin the composite Action by release tag
|
|
|
153
153
|
(`action.yml` lives at the repo root — no `/action` path segment):
|
|
154
154
|
|
|
155
155
|
```yaml
|
|
156
|
-
- uses: driftless-dev/driftless@v0.2.
|
|
156
|
+
- uses: driftless-dev/driftless@v0.2.9
|
|
157
157
|
with:
|
|
158
158
|
command: scan
|
|
159
159
|
```
|
|
@@ -161,9 +161,9 @@ After a release, users can pin the composite Action by release tag
|
|
|
161
161
|
Or pin the PyPI package in the Action input:
|
|
162
162
|
|
|
163
163
|
```yaml
|
|
164
|
-
- uses: driftless-dev/driftless@v0.2.
|
|
164
|
+
- uses: driftless-dev/driftless@v0.2.9
|
|
165
165
|
with:
|
|
166
|
-
version: "==0.2.
|
|
166
|
+
version: "==0.2.9"
|
|
167
167
|
command: migrate
|
|
168
168
|
```
|
|
169
169
|
|
|
@@ -171,7 +171,7 @@ Optionally maintain a floating **`v1`** tag on the latest stable minor release
|
|
|
171
171
|
(point it at the current release tag after each publish):
|
|
172
172
|
|
|
173
173
|
```bash
|
|
174
|
-
git tag -f v1 v0.2.
|
|
174
|
+
git tag -f v1 v0.2.9 && git push origin v1 --force
|
|
175
175
|
```
|
|
176
176
|
|
|
177
177
|
Update [`action.yml`](../action.yml) default `version` input when cutting releases.
|
|
@@ -428,7 +428,7 @@ driftless view -w support_classifier</code></pre>
|
|
|
428
428
|
<span class="tok-k">runs-on</span>: ubuntu-latest
|
|
429
429
|
<span class="tok-k">steps</span>:
|
|
430
430
|
- <span class="tok-k">uses</span>: actions/checkout@v4
|
|
431
|
-
- <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.
|
|
431
|
+
- <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.9
|
|
432
432
|
<span class="tok-k">with</span>:
|
|
433
433
|
<span class="tok-k">command</span>: <span class="tok-s">plan</span></code></pre>
|
|
434
434
|
<p>A scheduled <code class="inline">plan</code> gates CI when a deprecated model needs attention; a manually-triggered <code class="inline">migrate</code> opens a PR (or an issue when blocked) with the evidence attached.</p>
|
|
@@ -68,6 +68,9 @@ class RunSpec(StrictModel):
|
|
|
68
68
|
# the shell runner, which uses ``{{ model }}`` substitution / env_var).
|
|
69
69
|
model_param: str | None = None
|
|
70
70
|
timeout_seconds: int = 1800
|
|
71
|
+
# For endpoints: max parallel POSTs (1 = sequential). Output order always
|
|
72
|
+
# matches input order regardless of completion order.
|
|
73
|
+
endpoint_concurrency: int = 1
|
|
71
74
|
|
|
72
75
|
@field_validator("command", "endpoint")
|
|
73
76
|
@classmethod
|
|
@@ -76,6 +79,13 @@ class RunSpec(StrictModel):
|
|
|
76
79
|
raise ValueError("must not be blank")
|
|
77
80
|
return v
|
|
78
81
|
|
|
82
|
+
@field_validator("endpoint_concurrency")
|
|
83
|
+
@classmethod
|
|
84
|
+
def _endpoint_concurrency_range(cls, v: int) -> int:
|
|
85
|
+
if v < 1 or v > 32:
|
|
86
|
+
raise ValueError("run.endpoint_concurrency must be between 1 and 32")
|
|
87
|
+
return v
|
|
88
|
+
|
|
79
89
|
@model_validator(mode="after")
|
|
80
90
|
def _one_runner(self) -> "RunSpec":
|
|
81
91
|
if not self.command and not self.endpoint:
|
|
@@ -16,6 +16,7 @@ import sys
|
|
|
16
16
|
import time
|
|
17
17
|
import urllib.error
|
|
18
18
|
import urllib.request
|
|
19
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
@@ -198,6 +199,52 @@ def _read_jsonl(path: Path) -> list[dict]:
|
|
|
198
199
|
return records
|
|
199
200
|
|
|
200
201
|
|
|
202
|
+
def _endpoint_post_record(
|
|
203
|
+
index: int,
|
|
204
|
+
rec: dict,
|
|
205
|
+
*,
|
|
206
|
+
endpoint: str,
|
|
207
|
+
model: str,
|
|
208
|
+
model_param: str,
|
|
209
|
+
headers: dict[str, str],
|
|
210
|
+
timeout: float,
|
|
211
|
+
id_field: str | None,
|
|
212
|
+
) -> str:
|
|
213
|
+
"""POST one input record and return the output JSONL line."""
|
|
214
|
+
body = dict(rec)
|
|
215
|
+
body[model_param] = model
|
|
216
|
+
try:
|
|
217
|
+
text = _http_post(
|
|
218
|
+
endpoint, json.dumps(body).encode("utf-8"), headers, timeout
|
|
219
|
+
)
|
|
220
|
+
except urllib.error.HTTPError as exc:
|
|
221
|
+
raise HarnessError(
|
|
222
|
+
f"endpoint returned HTTP {exc.code} on record {index}",
|
|
223
|
+
hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
|
|
224
|
+
) from exc
|
|
225
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
226
|
+
raise HarnessError(
|
|
227
|
+
f"endpoint request failed on record {index}: {endpoint}",
|
|
228
|
+
hint=str(getattr(exc, "reason", exc)),
|
|
229
|
+
) from exc
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
obj = json.loads(text)
|
|
233
|
+
except json.JSONDecodeError as exc:
|
|
234
|
+
raise HarnessError(
|
|
235
|
+
f"endpoint returned non-JSON on record {index}",
|
|
236
|
+
hint=_tail(text) or "expected a JSON object per record",
|
|
237
|
+
) from exc
|
|
238
|
+
if not isinstance(obj, dict):
|
|
239
|
+
raise HarnessError(
|
|
240
|
+
f"endpoint response on record {index} must be a JSON object",
|
|
241
|
+
hint=f"got {type(obj).__name__}",
|
|
242
|
+
)
|
|
243
|
+
if id_field and id_field not in obj and id_field in rec:
|
|
244
|
+
obj[id_field] = rec[id_field]
|
|
245
|
+
return json.dumps(obj)
|
|
246
|
+
|
|
247
|
+
|
|
201
248
|
def _run_endpoint(
|
|
202
249
|
workflow: Workflow, model: str, *, cwd: Path, output_path: Path
|
|
203
250
|
) -> RunResult:
|
|
@@ -207,7 +254,8 @@ def _run_endpoint(
|
|
|
207
254
|
``run.model_param`` (default ``"model"``); the JSON response object is written
|
|
208
255
|
as the corresponding output record. When ``eval.id_field`` is set and the
|
|
209
256
|
response omits it, the input's id is copied through so output<->label
|
|
210
|
-
alignment still works.
|
|
257
|
+
alignment still works. Use ``run.endpoint_concurrency`` (>1) to POST in
|
|
258
|
+
parallel; output line order always matches the input file.
|
|
211
259
|
"""
|
|
212
260
|
run = workflow.run
|
|
213
261
|
input_path = (cwd / run.input_path).resolve()
|
|
@@ -225,48 +273,39 @@ def _run_endpoint(
|
|
|
225
273
|
if token:
|
|
226
274
|
headers["Authorization"] = f"Bearer {token}"
|
|
227
275
|
|
|
228
|
-
|
|
276
|
+
endpoint = run.endpoint
|
|
277
|
+
if endpoint is None:
|
|
278
|
+
raise HarnessError(
|
|
279
|
+
"no endpoint URL is configured",
|
|
280
|
+
hint="set run.endpoint in the contract",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
concurrency = run.endpoint_concurrency
|
|
284
|
+
timeout = float(run.timeout_seconds)
|
|
229
285
|
start = time.monotonic()
|
|
230
|
-
for i, rec in enumerate(records, start=1):
|
|
231
|
-
body = dict(rec)
|
|
232
|
-
body[model_param] = model
|
|
233
|
-
endpoint = run.endpoint
|
|
234
|
-
if endpoint is None:
|
|
235
|
-
raise HarnessError(
|
|
236
|
-
"no endpoint URL is configured",
|
|
237
|
-
hint="set run.endpoint in the contract",
|
|
238
|
-
)
|
|
239
|
-
try:
|
|
240
|
-
text = _http_post(
|
|
241
|
-
endpoint, json.dumps(body).encode("utf-8"), headers, run.timeout_seconds
|
|
242
|
-
)
|
|
243
|
-
except urllib.error.HTTPError as exc:
|
|
244
|
-
raise HarnessError(
|
|
245
|
-
f"endpoint returned HTTP {exc.code} on record {i}",
|
|
246
|
-
hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
|
|
247
|
-
) from exc
|
|
248
|
-
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
249
|
-
raise HarnessError(
|
|
250
|
-
f"endpoint request failed on record {i}: {run.endpoint}",
|
|
251
|
-
hint=str(getattr(exc, "reason", exc)),
|
|
252
|
-
) from exc
|
|
253
286
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
def post_one(index: int, rec: dict) -> tuple[int, str]:
|
|
288
|
+
line = _endpoint_post_record(
|
|
289
|
+
index,
|
|
290
|
+
rec,
|
|
291
|
+
endpoint=endpoint,
|
|
292
|
+
model=model,
|
|
293
|
+
model_param=model_param,
|
|
294
|
+
headers=headers,
|
|
295
|
+
timeout=timeout,
|
|
296
|
+
id_field=id_field,
|
|
297
|
+
)
|
|
298
|
+
return index, line
|
|
299
|
+
|
|
300
|
+
if concurrency <= 1 or len(records) <= 1:
|
|
301
|
+
indexed_lines = [post_one(i, rec) for i, rec in enumerate(records, start=1)]
|
|
302
|
+
else:
|
|
303
|
+
workers = min(concurrency, len(records))
|
|
304
|
+
pairs = [(i, rec) for i, rec in enumerate(records, start=1)]
|
|
305
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
306
|
+
indexed_lines = list(pool.map(lambda pair: post_one(pair[0], pair[1]), pairs))
|
|
269
307
|
|
|
308
|
+
out_lines = [line for _, line in indexed_lines]
|
|
270
309
|
duration = time.monotonic() - start
|
|
271
310
|
output_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
|
272
311
|
return RunResult(
|
|
@@ -274,7 +313,7 @@ def _run_endpoint(
|
|
|
274
313
|
output_path=output_path,
|
|
275
314
|
returncode=0,
|
|
276
315
|
duration_seconds=duration,
|
|
277
|
-
stdout=f"{len(out_lines)} records via {
|
|
316
|
+
stdout=f"{len(out_lines)} records via {endpoint} (concurrency={concurrency})",
|
|
278
317
|
stderr="",
|
|
279
318
|
env_overrides={},
|
|
280
319
|
)
|
|
@@ -118,3 +118,48 @@ def test_run_rejects_both_command_and_endpoint():
|
|
|
118
118
|
RunSpec.model_validate(
|
|
119
119
|
{"command": "echo hi", "endpoint": "http://x", "input_path": "i", "output_path": "o"}
|
|
120
120
|
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_endpoint_concurrency_must_be_in_range():
|
|
124
|
+
with pytest.raises(ValueError, match="endpoint_concurrency"):
|
|
125
|
+
RunSpec.model_validate(
|
|
126
|
+
{
|
|
127
|
+
"endpoint": "http://x",
|
|
128
|
+
"input_path": "i",
|
|
129
|
+
"output_path": "o",
|
|
130
|
+
"endpoint_concurrency": 0,
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_endpoint_concurrency_preserves_order_and_parallelizes(tmp_path, monkeypatch):
|
|
136
|
+
import threading
|
|
137
|
+
import time
|
|
138
|
+
|
|
139
|
+
records = [{"id": str(i), "text": "x"} for i in range(8)]
|
|
140
|
+
_write_inputs(tmp_path, records)
|
|
141
|
+
lock = threading.Lock()
|
|
142
|
+
in_flight = 0
|
|
143
|
+
max_in_flight = 0
|
|
144
|
+
|
|
145
|
+
def fake_post(url, payload, headers, timeout):
|
|
146
|
+
nonlocal in_flight, max_in_flight
|
|
147
|
+
body = json.loads(payload.decode("utf-8"))
|
|
148
|
+
with lock:
|
|
149
|
+
in_flight += 1
|
|
150
|
+
max_in_flight = max(max_in_flight, in_flight)
|
|
151
|
+
time.sleep(0.03)
|
|
152
|
+
with lock:
|
|
153
|
+
in_flight -= 1
|
|
154
|
+
return json.dumps({"id": body["id"], "label": "ok"})
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(harness, "_http_post", fake_post)
|
|
157
|
+
result = run_workflow(
|
|
158
|
+
_endpoint_workflow(endpoint_concurrency=4), "m1", cwd=tmp_path
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert result.ok
|
|
162
|
+
assert max_in_flight >= 3
|
|
163
|
+
rows = _read_out(result.output_path)
|
|
164
|
+
assert [r["id"] for r in rows] == [str(i) for i in range(8)]
|
|
165
|
+
assert "concurrency=4" in result.stdout
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
|
8
|
+
|
|
9
|
+
import fetch_provider_models as fpm # noqa: E402
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _catalog(models) -> Path:
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
p = Path(tempfile.mkdtemp()) / "cat.json"
|
|
16
|
+
p.write_text(json.dumps({"models": models}), encoding="utf-8")
|
|
17
|
+
return p
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_discover_new_models_skips_known_and_filters_openai(tmp_path):
|
|
21
|
+
cat = _catalog(
|
|
22
|
+
[
|
|
23
|
+
{"model": "gpt-4o", "provider": "openai"},
|
|
24
|
+
{"model": "claude-3-5-sonnet", "provider": "anthropic"},
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def fake_fetch(_key):
|
|
29
|
+
return [
|
|
30
|
+
"gpt-4o", # known
|
|
31
|
+
"gpt-5-mini", # new
|
|
32
|
+
"ft:gpt-4o:org:123", # fine-tune — skip
|
|
33
|
+
"tts-1", # infra — skip
|
|
34
|
+
"whisper-1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
updates = fpm.discover_new_models(
|
|
38
|
+
provider="openai",
|
|
39
|
+
catalog_path=cat,
|
|
40
|
+
fetch_ids=fake_fetch,
|
|
41
|
+
keep=fpm._keep_openai,
|
|
42
|
+
api_key="k",
|
|
43
|
+
)
|
|
44
|
+
assert [u["model"] for u in updates] == ["gpt-5-mini"]
|
|
45
|
+
assert updates[0]["status"] == "active"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_discover_new_models_anthropic_claude_only(tmp_path):
|
|
49
|
+
cat = _catalog([{"model": "claude-3-5-sonnet", "provider": "anthropic"}])
|
|
50
|
+
|
|
51
|
+
updates = fpm.discover_new_models(
|
|
52
|
+
provider="anthropic",
|
|
53
|
+
catalog_path=cat,
|
|
54
|
+
fetch_ids=lambda _k: ["claude-3-5-sonnet", "claude-3-7-sonnet", "not-a-model"],
|
|
55
|
+
keep=fpm._keep_anthropic,
|
|
56
|
+
api_key="k",
|
|
57
|
+
)
|
|
58
|
+
assert [u["model"] for u in updates] == ["claude-3-7-sonnet"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_fetch_updates_merges_providers_and_skips_missing_keys(tmp_path, monkeypatch):
|
|
62
|
+
cat = _catalog([{"model": "gpt-4o", "provider": "openai"}])
|
|
63
|
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
64
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
65
|
+
|
|
66
|
+
updates = fpm.fetch_updates(["openai", "anthropic"], catalog_path=cat)
|
|
67
|
+
assert updates == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_fetch_updates_openai(monkeypatch, tmp_path):
|
|
71
|
+
cat = _catalog([{"model": "gpt-4o", "provider": "openai"}])
|
|
72
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sekret")
|
|
73
|
+
monkeypatch.setattr(
|
|
74
|
+
fpm,
|
|
75
|
+
"_openai_model_ids",
|
|
76
|
+
lambda key: (["gpt-4o", "o3-mini"] if key == "sekret" else []),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
updates = fpm.fetch_updates(["openai"], catalog_path=cat)
|
|
80
|
+
assert [u["model"] for u in updates] == ["o3-mini"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cli_writes_output(tmp_path, monkeypatch):
|
|
84
|
+
cat = tmp_path / "cat.json"
|
|
85
|
+
cat.write_text(json.dumps({"models": []}), encoding="utf-8")
|
|
86
|
+
out = tmp_path / "updates.json"
|
|
87
|
+
monkeypatch.setattr(
|
|
88
|
+
fpm,
|
|
89
|
+
"fetch_updates",
|
|
90
|
+
lambda providers, catalog_path: [
|
|
91
|
+
{"model": "gpt-5", "provider": "openai", "status": "active"}
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
assert fpm.main(["--provider", "openai", "--catalog", str(cat), "-o", str(out)]) == 0
|
|
95
|
+
data = json.loads(out.read_text(encoding="utf-8"))
|
|
96
|
+
assert data[0]["model"] == "gpt-5"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_http_get_json_raises_on_http_error(monkeypatch):
|
|
100
|
+
import urllib.error
|
|
101
|
+
|
|
102
|
+
class FakeHTTPError(urllib.error.HTTPError):
|
|
103
|
+
def __init__(self):
|
|
104
|
+
super().__init__(url="http://x", code=401, msg="nope", hdrs={}, fp=None)
|
|
105
|
+
|
|
106
|
+
def boom(*a, **k):
|
|
107
|
+
raise FakeHTTPError()
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(fpm.urllib.request, "urlopen", boom)
|
|
110
|
+
with pytest.raises(RuntimeError, match="HTTP 401"):
|
|
111
|
+
fpm._http_get_json("http://x", {})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|