fixtureforge 2.0.2__tar.gz → 2.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.
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/PKG-INFO +8 -2
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/README.md +7 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/pyproject.toml +9 -2
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/__init__.py +1 -1
- fixtureforge-2.2.0/src/fixtureforge/ai/__init__.py +25 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/_audit_log.py +44 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/_groq_client.py +109 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/failure_analyzer.py +253 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/semantic_assert.py +252 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/generator.py +18 -12
- fixtureforge-2.2.0/src/fixtureforge/pytest_plugin.py +264 -0
- fixtureforge-2.0.2/src/fixtureforge/ai/__init__.py +0 -10
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/LICENSE +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/cache.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/engine.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/prompts.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/cli/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/cli/commands.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/config/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/config/flags.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/analyzer.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/batch_engine.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/compression.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/dataset.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/exporter.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/parser.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/recipe.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/router.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/streamer.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/swarm.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/github.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/jira.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/sharing.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/storage.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/dream.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/store.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/anthropic.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/base.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/factory.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/gemini.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/groq.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/ollama.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/openai.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/pyproject.toml +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/security/__init__.py +0 -0
- {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/security/permissions.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureforge
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Agentic Test Data Harness: memory, multi-agent swarms, permission gates, coverage analysis. Provider-agnostic (Gemini, OpenAI, Anthropic, Ollama).
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Keywords:
|
|
7
|
+
Keywords: pytest,fixtures,test-data,pydantic,faker,factory,ai,testing,mock-data,qa,automation,synthetic-data,llm,test-fixtures,fixture-generator
|
|
8
8
|
Author: Yaniv Metuku
|
|
9
9
|
Requires-Python: >=3.11,<4.0
|
|
10
10
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -32,6 +32,10 @@ Project-URL: Homepage, https://fixtureforge.dev
|
|
|
32
32
|
Project-URL: Repository, https://github.com/Yaniv2809/fixtureforge
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="fixtureforge-logo.png" alt="FixtureForge Logo" width="400"/>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
35
39
|
# FixtureForge
|
|
36
40
|
|
|
37
41
|
**Agentic Test Data Harness for Python.**
|
|
@@ -421,7 +425,9 @@ MIT — see [LICENSE](LICENSE).
|
|
|
421
425
|
|
|
422
426
|
## Links
|
|
423
427
|
|
|
428
|
+
- **Docs**: https://yaniv2809.github.io/fixtureforge/
|
|
424
429
|
- **PyPI**: https://pypi.org/project/fixtureforge/
|
|
425
430
|
- **Repository**: https://github.com/Yaniv2809/fixtureforge
|
|
426
431
|
- **Issues**: https://github.com/Yaniv2809/fixtureforge/issues
|
|
427
432
|
|
|
433
|
+
💬 [Join the discussion](https://github.com/Yaniv2809/fixtureforge/discussions/1)
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="fixtureforge-logo.png" alt="FixtureForge Logo" width="400"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# FixtureForge
|
|
2
6
|
|
|
3
7
|
**Agentic Test Data Harness for Python.**
|
|
@@ -387,6 +391,9 @@ MIT — see [LICENSE](LICENSE).
|
|
|
387
391
|
|
|
388
392
|
## Links
|
|
389
393
|
|
|
394
|
+
- **Docs**: https://yaniv2809.github.io/fixtureforge/
|
|
390
395
|
- **PyPI**: https://pypi.org/project/fixtureforge/
|
|
391
396
|
- **Repository**: https://github.com/Yaniv2809/fixtureforge
|
|
392
397
|
- **Issues**: https://github.com/Yaniv2809/fixtureforge/issues
|
|
398
|
+
|
|
399
|
+
💬 [Join the discussion](https://github.com/Yaniv2809/fixtureforge/discussions/1)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "fixtureforge"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
description = "Agentic Test Data Harness: memory, multi-agent swarms, permission gates, coverage analysis. Provider-agnostic (Gemini, OpenAI, Anthropic, Ollama)."
|
|
5
5
|
authors = ["Yaniv Metuku"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
homepage = "https://fixtureforge.dev"
|
|
9
9
|
repository = "https://github.com/Yaniv2809/fixtureforge"
|
|
10
|
-
keywords = ["
|
|
10
|
+
keywords = ["pytest", "fixtures", "test-data", "pydantic", "faker", "factory", "ai", "testing", "mock-data", "qa", "automation", "synthetic-data", "llm", "test-fixtures", "fixture-generator"]
|
|
11
11
|
|
|
12
12
|
# ---------------------------------------------------------------------------
|
|
13
13
|
# Core dependencies — always installed, no AI required
|
|
@@ -62,6 +62,13 @@ mypy = "^1.8.0"
|
|
|
62
62
|
[tool.poetry.scripts]
|
|
63
63
|
forge = "fixtureforge.cli.commands:cli"
|
|
64
64
|
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# pytest plugin — auto-registered when fixtureforge is installed
|
|
67
|
+
# Makes the ``forge`` fixture available in every test without any conftest.py
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
[tool.poetry.plugins."pytest11"]
|
|
70
|
+
fixtureforge = "fixtureforge.pytest_plugin"
|
|
71
|
+
|
|
65
72
|
[build-system]
|
|
66
73
|
requires = ["poetry-core"]
|
|
67
74
|
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI-powered generation and validation for FixtureForge.
|
|
3
|
+
|
|
4
|
+
Core (always available):
|
|
5
|
+
from fixtureforge.ai import AIEngine, ResponseCache
|
|
6
|
+
|
|
7
|
+
v2.2.0 — Opt-in AI features (require GROQ_API_KEY for LLM mode):
|
|
8
|
+
from fixtureforge.ai import assert_semantic_match, SemanticResult
|
|
9
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
10
|
+
"""
|
|
11
|
+
from .engine import AIEngine
|
|
12
|
+
from .cache import ResponseCache
|
|
13
|
+
from .semantic_assert import assert_semantic_match, SemanticResult, SemanticAssertionError
|
|
14
|
+
from .failure_analyzer import SmartFailureAnalyzer
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Core
|
|
18
|
+
"AIEngine",
|
|
19
|
+
"ResponseCache",
|
|
20
|
+
# v2.2.0 features
|
|
21
|
+
"assert_semantic_match",
|
|
22
|
+
"SemanticResult",
|
|
23
|
+
"SemanticAssertionError",
|
|
24
|
+
"SmartFailureAnalyzer",
|
|
25
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit log for FixtureForge AI features.
|
|
3
|
+
|
|
4
|
+
Every LLM call made by assert_semantic_match or SmartFailureAnalyzer
|
|
5
|
+
is appended here as a JSONL record. Zero API calls are made during import.
|
|
6
|
+
|
|
7
|
+
Log location: .fixtureforge_ai_log.jsonl (project root / cwd)
|
|
8
|
+
Format: one JSON object per line, UTF-8, append-only.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
LOG_FILENAME = ".fixtureforge_ai_log.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _log_path() -> Path:
|
|
22
|
+
"""Return the audit log path relative to the current working directory."""
|
|
23
|
+
return Path(os.getcwd()) / LOG_FILENAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_event(event_type: str, data: dict[str, Any]) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Append one event record to the audit log.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
event_type: Short label — e.g. "semantic_assert_llm", "failure_analysis".
|
|
32
|
+
data: Dict of arbitrary key-value pairs to record.
|
|
33
|
+
"""
|
|
34
|
+
record = {
|
|
35
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
36
|
+
"event": event_type,
|
|
37
|
+
**data,
|
|
38
|
+
}
|
|
39
|
+
try:
|
|
40
|
+
with open(_log_path(), "a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
42
|
+
except OSError:
|
|
43
|
+
# Never crash a test run because of a logging failure.
|
|
44
|
+
pass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight Groq REST client for FixtureForge AI features.
|
|
3
|
+
|
|
4
|
+
Uses only `requests` (already a core dependency) — no extra pip install needed.
|
|
5
|
+
Model: llama3-8b-8192 (fast, free tier, sufficient for analysis tasks).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from fixtureforge.ai._groq_client import groq_complete, LLMClientError
|
|
9
|
+
response = groq_complete(system="You are...", user="Analyze this...")
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
21
|
+
DEFAULT_MODEL = "llama3-8b-8192"
|
|
22
|
+
DEFAULT_TIMEOUT = 10
|
|
23
|
+
DEFAULT_RETRIES = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMClientError(RuntimeError):
|
|
27
|
+
"""Raised when the Groq API call fails after all retries."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def groq_complete(
|
|
31
|
+
system: str,
|
|
32
|
+
user: str,
|
|
33
|
+
model: str = DEFAULT_MODEL,
|
|
34
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
35
|
+
retries: int = DEFAULT_RETRIES,
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Send a chat completion request to Groq and return the response text.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
system: System prompt (role definition / instructions).
|
|
43
|
+
user: User message (the actual task / input).
|
|
44
|
+
model: Groq model identifier. Default: llama3-8b-8192.
|
|
45
|
+
timeout: Request timeout in seconds.
|
|
46
|
+
retries: Number of retry attempts on transient errors.
|
|
47
|
+
api_key: Override GROQ_API_KEY env var (optional).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The model's response as a plain string.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
LLMClientError: If GROQ_API_KEY is not set or all retries fail.
|
|
54
|
+
"""
|
|
55
|
+
key = api_key or os.environ.get("GROQ_API_KEY", "")
|
|
56
|
+
if not key:
|
|
57
|
+
raise LLMClientError(
|
|
58
|
+
"GROQ_API_KEY is not set. "
|
|
59
|
+
"Export it with: export GROQ_API_KEY=gsk_... "
|
|
60
|
+
"or set it in your .env file."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
headers = {
|
|
64
|
+
"Authorization": f"Bearer {key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
}
|
|
67
|
+
payload = {
|
|
68
|
+
"model": model,
|
|
69
|
+
"messages": [
|
|
70
|
+
{"role": "system", "content": system},
|
|
71
|
+
{"role": "user", "content": user},
|
|
72
|
+
],
|
|
73
|
+
"temperature": 0.2,
|
|
74
|
+
"max_tokens": 512,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
last_error: Optional[Exception] = None
|
|
78
|
+
for attempt in range(retries + 1):
|
|
79
|
+
try:
|
|
80
|
+
resp = requests.post(
|
|
81
|
+
GROQ_API_URL,
|
|
82
|
+
headers=headers,
|
|
83
|
+
json=payload,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
data = resp.json()
|
|
88
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
89
|
+
except requests.exceptions.Timeout as exc:
|
|
90
|
+
last_error = exc
|
|
91
|
+
if attempt < retries:
|
|
92
|
+
time.sleep(1.0 * (attempt + 1))
|
|
93
|
+
except requests.exceptions.HTTPError as exc:
|
|
94
|
+
# 429 rate limit — back off and retry
|
|
95
|
+
if resp.status_code == 429 and attempt < retries:
|
|
96
|
+
time.sleep(2.0 * (attempt + 1))
|
|
97
|
+
last_error = exc
|
|
98
|
+
else:
|
|
99
|
+
raise LLMClientError(
|
|
100
|
+
f"Groq API returned HTTP {resp.status_code}: {resp.text[:200]}"
|
|
101
|
+
) from exc
|
|
102
|
+
except (KeyError, ValueError, json.JSONDecodeError) as exc:
|
|
103
|
+
raise LLMClientError(
|
|
104
|
+
f"Unexpected Groq response format: {exc}"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
raise LLMClientError(
|
|
108
|
+
f"Groq request failed after {retries + 1} attempts: {last_error}"
|
|
109
|
+
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SmartFailureAnalyzer — AI-powered pytest failure diagnostics.
|
|
3
|
+
|
|
4
|
+
Hooks into pytest's report pipeline and analyzes FAILED tests using Groq.
|
|
5
|
+
Prints a structured diagnosis to the console after each failure.
|
|
6
|
+
|
|
7
|
+
IMPORTANT:
|
|
8
|
+
- Never modifies test results or retry logic
|
|
9
|
+
- Never changes the failure status of any test
|
|
10
|
+
- Only activated on explicit opt-in (add to conftest.py)
|
|
11
|
+
- Requires GROQ_API_KEY — silently skips analysis if not set
|
|
12
|
+
|
|
13
|
+
Setup (conftest.py):
|
|
14
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
15
|
+
pytest_plugins = [SmartFailureAnalyzer()] # pass an instance
|
|
16
|
+
|
|
17
|
+
Or register via pytest plugin list:
|
|
18
|
+
# conftest.py
|
|
19
|
+
from fixtureforge.ai.failure_analyzer import SmartFailureAnalyzer
|
|
20
|
+
def pytest_configure(config):
|
|
21
|
+
config.pluginmanager.register(SmartFailureAnalyzer(), "fixtureforge-analyzer")
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from ._audit_log import log_event
|
|
31
|
+
from ._groq_client import LLMClientError, groq_complete
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Terminal encoding safety
|
|
35
|
+
# Detect if the terminal supports Unicode box-drawing characters.
|
|
36
|
+
# Windows cmd / PowerShell with cp1255 / cp1252 encoding cannot render them.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _supports_unicode() -> bool:
|
|
40
|
+
enc = getattr(sys.stdout, "encoding", None) or ""
|
|
41
|
+
return enc.lower().replace("-", "") in ("utf8", "utf8sig")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_USE_UNICODE = _supports_unicode()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _box(title: str, lines: list[str]) -> str:
|
|
48
|
+
"""Render a labeled box. ASCII-safe on non-UTF-8 terminals."""
|
|
49
|
+
if _USE_UNICODE:
|
|
50
|
+
width = max(len(title) + 4, max((len(l) for l in lines), default=0) + 4, 50)
|
|
51
|
+
top = f"\u2554{'=' * (width - 2)}\u2557"
|
|
52
|
+
header = f"\u2551 {title:<{width - 4}} \u2551"
|
|
53
|
+
sep = f"\u2560{'-' * (width - 2)}\u2563"
|
|
54
|
+
bottom = f"\u255a{'=' * (width - 2)}\u255d"
|
|
55
|
+
body = "\n".join(f"\u2551 {l:<{width - 4}} \u2551" for l in lines)
|
|
56
|
+
return "\n".join([top, header, sep, body, bottom])
|
|
57
|
+
else:
|
|
58
|
+
width = max(len(title) + 4, max((len(l) for l in lines), default=0) + 4, 50)
|
|
59
|
+
border = "+" + "=" * (width - 2) + "+"
|
|
60
|
+
divider = "+" + "-" * (width - 2) + "+"
|
|
61
|
+
header = f"| {title:<{width - 4}} |"
|
|
62
|
+
body = "\n".join(f"| {l:<{width - 4}} |" for l in lines)
|
|
63
|
+
return "\n".join([border, header, divider, body, border])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Groq prompt
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_SYSTEM_PROMPT = (
|
|
71
|
+
"You are a senior QA engineer and Python expert. "
|
|
72
|
+
"Analyze test failures and provide concise, actionable diagnostics. "
|
|
73
|
+
"Return ONLY valid JSON — no markdown, no explanation outside the JSON. "
|
|
74
|
+
"Format: {"
|
|
75
|
+
'"explanation": "one sentence describing what went wrong", '
|
|
76
|
+
'"likely_cause": "root cause in plain English", '
|
|
77
|
+
'"suggested_fix": "specific code-level action to resolve it"'
|
|
78
|
+
"}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_TAIL_LINES = 20 # number of traceback lines to send
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# SmartFailureAnalyzer plugin
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
class SmartFailureAnalyzer:
|
|
89
|
+
"""
|
|
90
|
+
pytest plugin that analyzes FAILED tests with Groq AI.
|
|
91
|
+
|
|
92
|
+
Behavior:
|
|
93
|
+
- Triggers only when a test's call phase fails
|
|
94
|
+
- Sends: test ID + exception type + exception message + last 20 traceback lines
|
|
95
|
+
- Prints a structured analysis block to stdout
|
|
96
|
+
- Logs every interaction to .fixtureforge_ai_log.jsonl
|
|
97
|
+
- NEVER modifies report.passed or any test state
|
|
98
|
+
|
|
99
|
+
Registration (conftest.py):
|
|
100
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
101
|
+
def pytest_configure(config):
|
|
102
|
+
config.pluginmanager.register(SmartFailureAnalyzer(), "forge-analyzer")
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, api_key: Optional[str] = None) -> None:
|
|
106
|
+
self._api_key = api_key
|
|
107
|
+
self._analyzed: int = 0
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# pytest hook
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def pytest_runtest_logreport(self, report: Any) -> None:
|
|
114
|
+
"""Called by pytest after each test phase (setup / call / teardown)."""
|
|
115
|
+
if report.when != "call" or not report.failed:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Skip if GROQ_API_KEY is not available — never block the test run
|
|
119
|
+
key = self._api_key or os.environ.get("GROQ_API_KEY", "")
|
|
120
|
+
if not key:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self._analyze(report, api_key=key)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Analysis
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _extract_failure_info(self, report: Any) -> dict[str, str]:
|
|
130
|
+
"""Pull structured info out of a pytest report object."""
|
|
131
|
+
nodeid = getattr(report, "nodeid", "unknown_test")
|
|
132
|
+
longrepr = report.longrepr
|
|
133
|
+
|
|
134
|
+
# longrepr can be a string, tuple, or ExceptionInfo object
|
|
135
|
+
if isinstance(longrepr, str):
|
|
136
|
+
full_tb = longrepr
|
|
137
|
+
exc_type = "UnknownError"
|
|
138
|
+
exc_msg = longrepr[:300]
|
|
139
|
+
elif isinstance(longrepr, tuple):
|
|
140
|
+
# (path, lineno, message) format from some pytest versions
|
|
141
|
+
full_tb = "\n".join(str(p) for p in longrepr)
|
|
142
|
+
exc_type = "Error"
|
|
143
|
+
exc_msg = str(longrepr[-1])[:300]
|
|
144
|
+
else:
|
|
145
|
+
# ExceptionInfo / ReprExceptionInfo
|
|
146
|
+
full_tb = str(longrepr)
|
|
147
|
+
lines = full_tb.splitlines()
|
|
148
|
+
exc_type = "UnknownError"
|
|
149
|
+
exc_msg = ""
|
|
150
|
+
# find the last "E " line — that's the exception message
|
|
151
|
+
for line in reversed(lines):
|
|
152
|
+
stripped = line.strip()
|
|
153
|
+
if stripped.startswith("E "):
|
|
154
|
+
exc_msg = stripped[2:].strip()
|
|
155
|
+
break
|
|
156
|
+
# find exception class name
|
|
157
|
+
for line in reversed(lines):
|
|
158
|
+
for keyword in ("Error", "Exception", "Failure", "assert"):
|
|
159
|
+
if keyword in line:
|
|
160
|
+
exc_type = line.strip()[:80]
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
# last N lines of traceback (avoid huge prompts)
|
|
164
|
+
tail_lines = full_tb.splitlines()[-_TAIL_LINES:]
|
|
165
|
+
tail = "\n".join(tail_lines)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"nodeid": nodeid,
|
|
169
|
+
"exc_type": exc_type,
|
|
170
|
+
"exc_msg": exc_msg,
|
|
171
|
+
"tail": tail,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _call_groq(self, info: dict[str, str], api_key: str) -> dict[str, str]:
|
|
175
|
+
"""Send failure info to Groq and parse structured JSON response."""
|
|
176
|
+
user_msg = (
|
|
177
|
+
f"Test: {info['nodeid']}\n"
|
|
178
|
+
f"Error type: {info['exc_type']}\n"
|
|
179
|
+
f"Error message: {info['exc_msg']}\n"
|
|
180
|
+
f"Traceback (last {_TAIL_LINES} lines):\n{info['tail']}"
|
|
181
|
+
)
|
|
182
|
+
raw = groq_complete(
|
|
183
|
+
system=_SYSTEM_PROMPT,
|
|
184
|
+
user=user_msg,
|
|
185
|
+
api_key=api_key,
|
|
186
|
+
)
|
|
187
|
+
# strip markdown fences if model ignored instructions
|
|
188
|
+
clean = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
|
189
|
+
parsed = json.loads(clean)
|
|
190
|
+
return {
|
|
191
|
+
"explanation": str(parsed.get("explanation", "")),
|
|
192
|
+
"likely_cause": str(parsed.get("likely_cause", "")),
|
|
193
|
+
"suggested_fix": str(parsed.get("suggested_fix", "")),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _print_analysis(self, info: dict[str, str], analysis: dict[str, str]) -> None:
|
|
197
|
+
"""Print the analysis block to stdout — encoding-safe."""
|
|
198
|
+
label = "FixtureForge AI Analysis"
|
|
199
|
+
lines = [
|
|
200
|
+
f"Test : {info['nodeid']}",
|
|
201
|
+
"",
|
|
202
|
+
f"Cause : {analysis['likely_cause']}",
|
|
203
|
+
"",
|
|
204
|
+
f"Detail : {analysis['explanation']}",
|
|
205
|
+
"",
|
|
206
|
+
f"Fix : {analysis['suggested_fix']}",
|
|
207
|
+
]
|
|
208
|
+
box = _box(label, lines)
|
|
209
|
+
# Write via sys.stdout with explicit encoding fallback
|
|
210
|
+
try:
|
|
211
|
+
print("\n" + box + "\n", flush=True)
|
|
212
|
+
except UnicodeEncodeError:
|
|
213
|
+
safe = (box
|
|
214
|
+
.encode(sys.stdout.encoding or "ascii", errors="replace")
|
|
215
|
+
.decode(sys.stdout.encoding or "ascii"))
|
|
216
|
+
print("\n" + safe + "\n", flush=True)
|
|
217
|
+
|
|
218
|
+
def _analyze(self, report: Any, api_key: str) -> None:
|
|
219
|
+
"""Full analysis pipeline for one failed test."""
|
|
220
|
+
info = self._extract_failure_info(report)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
analysis = self._call_groq(info, api_key=api_key)
|
|
224
|
+
except (LLMClientError, json.JSONDecodeError, KeyError) as exc:
|
|
225
|
+
log_event("failure_analysis_error", {
|
|
226
|
+
"nodeid": info["nodeid"],
|
|
227
|
+
"error": str(exc),
|
|
228
|
+
})
|
|
229
|
+
return # silently skip — never interrupt the test session
|
|
230
|
+
|
|
231
|
+
self._print_analysis(info, analysis)
|
|
232
|
+
self._analyzed += 1
|
|
233
|
+
|
|
234
|
+
log_event("failure_analysis", {
|
|
235
|
+
"nodeid": info["nodeid"],
|
|
236
|
+
"exc_type": info["exc_type"],
|
|
237
|
+
"explanation": analysis["explanation"],
|
|
238
|
+
"likely_cause": analysis["likely_cause"],
|
|
239
|
+
"suggested_fix": analysis["suggested_fix"],
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Summary (optional hook)
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def pytest_terminal_summary(self, terminalreporter: Any, exitstatus: int) -> None:
|
|
247
|
+
"""Append a one-line summary at the end of the pytest session."""
|
|
248
|
+
if self._analyzed > 0:
|
|
249
|
+
terminalreporter.write_sep(
|
|
250
|
+
"-",
|
|
251
|
+
f"FixtureForge AI: analyzed {self._analyzed} failure(s) -- "
|
|
252
|
+
f"see .fixtureforge_ai_log.jsonl for full audit trail",
|
|
253
|
+
)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
assert_semantic_match — semantic validation for AI-generated or natural-language outputs.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
Local (default, use_llm=False):
|
|
6
|
+
Cosine similarity on TF-IDF vectors built from stdlib only.
|
|
7
|
+
Zero API calls. Zero cost. Always available offline.
|
|
8
|
+
|
|
9
|
+
LLM (use_llm=True):
|
|
10
|
+
Sends actual + expected_intent to Groq for a structured judgment.
|
|
11
|
+
Requires GROQ_API_KEY. Every call is logged to .fixtureforge_ai_log.jsonl.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from fixtureforge.ai import assert_semantic_match
|
|
15
|
+
|
|
16
|
+
# Local (no API key needed)
|
|
17
|
+
result = assert_semantic_match(
|
|
18
|
+
actual="The product was delivered on time and in perfect condition.",
|
|
19
|
+
expected_intent="positive delivery experience",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# LLM-powered (explicit opt-in)
|
|
23
|
+
result = assert_semantic_match(
|
|
24
|
+
actual=chatbot_response,
|
|
25
|
+
expected_intent="politely decline the refund request",
|
|
26
|
+
use_llm=True,
|
|
27
|
+
threshold=0.8,
|
|
28
|
+
)
|
|
29
|
+
# Raises SemanticAssertionError if result.passed is False
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
from collections import Counter
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
from ._audit_log import log_event
|
|
40
|
+
from ._groq_client import LLMClientError, groq_complete
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Public types
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SemanticResult:
|
|
48
|
+
"""Return value of assert_semantic_match."""
|
|
49
|
+
passed: bool
|
|
50
|
+
score: float # 0.0 – 1.0
|
|
51
|
+
reason: str # human-readable explanation
|
|
52
|
+
method: str # "local" or "llm"
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
status = "PASS" if self.passed else "FAIL"
|
|
56
|
+
return (
|
|
57
|
+
f"SemanticResult({status} | score={self.score:.2f} "
|
|
58
|
+
f"| method={self.method} | reason={self.reason!r})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SemanticAssertionError(AssertionError):
|
|
63
|
+
"""Raised when assert_semantic_match fails."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, result: SemanticResult, actual: str, expected_intent: str) -> None:
|
|
66
|
+
self.result = result
|
|
67
|
+
self.actual = actual
|
|
68
|
+
self.expected_intent = expected_intent
|
|
69
|
+
super().__init__(
|
|
70
|
+
f"\n\nSemantic assertion failed.\n"
|
|
71
|
+
f" Expected intent : {expected_intent!r}\n"
|
|
72
|
+
f" Actual output : {actual!r}\n"
|
|
73
|
+
f" Score : {result.score:.2f} (threshold not met)\n"
|
|
74
|
+
f" Method : {result.method}\n"
|
|
75
|
+
f" Reason : {result.reason}\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Local cosine similarity (stdlib only — no external dependencies)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _tokenize(text: str) -> Counter:
|
|
84
|
+
"""Lowercase, split on whitespace, return token frequency counter."""
|
|
85
|
+
tokens = text.lower().split()
|
|
86
|
+
# strip punctuation from token edges
|
|
87
|
+
cleaned = [t.strip(".,!?;:\"'()-[]{}") for t in tokens]
|
|
88
|
+
return Counter(t for t in cleaned if t)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cosine_similarity(text_a: str, text_b: str) -> float:
|
|
92
|
+
"""
|
|
93
|
+
TF-IDF cosine similarity between two strings using stdlib only.
|
|
94
|
+
Returns a value in [0.0, 1.0].
|
|
95
|
+
"""
|
|
96
|
+
vec_a = _tokenize(text_a)
|
|
97
|
+
vec_b = _tokenize(text_b)
|
|
98
|
+
|
|
99
|
+
if not vec_a or not vec_b:
|
|
100
|
+
return 0.0
|
|
101
|
+
|
|
102
|
+
# dot product over shared tokens
|
|
103
|
+
shared = set(vec_a) & set(vec_b)
|
|
104
|
+
dot = sum(vec_a[w] * vec_b[w] for w in shared)
|
|
105
|
+
|
|
106
|
+
# magnitudes
|
|
107
|
+
mag_a = math.sqrt(sum(v * v for v in vec_a.values()))
|
|
108
|
+
mag_b = math.sqrt(sum(v * v for v in vec_b.values()))
|
|
109
|
+
|
|
110
|
+
if mag_a == 0.0 or mag_b == 0.0:
|
|
111
|
+
return 0.0
|
|
112
|
+
|
|
113
|
+
return dot / (mag_a * mag_b)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _local_assert(actual: str, expected_intent: str, threshold: float) -> SemanticResult:
|
|
117
|
+
score = _cosine_similarity(actual, expected_intent)
|
|
118
|
+
passed = score >= threshold
|
|
119
|
+
reason = (
|
|
120
|
+
f"Cosine similarity {score:.2f} >= threshold {threshold:.2f}"
|
|
121
|
+
if passed
|
|
122
|
+
else f"Cosine similarity {score:.2f} < threshold {threshold:.2f}"
|
|
123
|
+
)
|
|
124
|
+
return SemanticResult(passed=passed, score=score, reason=reason, method="local")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# LLM-powered assertion via Groq
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
_LLM_SYSTEM = (
|
|
132
|
+
"You are a test validation judge. "
|
|
133
|
+
"Given an actual output and an expected intent, "
|
|
134
|
+
"return ONLY valid JSON with no markdown, no explanation. "
|
|
135
|
+
'Format: {"passed": bool, "score": float, "reason": str}'
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _llm_assert(
|
|
140
|
+
actual: str,
|
|
141
|
+
expected_intent: str,
|
|
142
|
+
threshold: float,
|
|
143
|
+
api_key: Optional[str],
|
|
144
|
+
) -> SemanticResult:
|
|
145
|
+
user_msg = (
|
|
146
|
+
f"Expected intent: {expected_intent}\n"
|
|
147
|
+
f"Actual output: {actual}\n"
|
|
148
|
+
f"Score threshold: {threshold}\n"
|
|
149
|
+
"Does the actual output satisfy the expected intent? "
|
|
150
|
+
"Return JSON only."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
raw = groq_complete(system=_LLM_SYSTEM, user=user_msg, api_key=api_key)
|
|
155
|
+
# strip markdown fences if model ignored instructions
|
|
156
|
+
raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
|
157
|
+
parsed = json.loads(raw)
|
|
158
|
+
|
|
159
|
+
score = float(parsed.get("score", 0.0))
|
|
160
|
+
passed = bool(parsed.get("passed", score >= threshold))
|
|
161
|
+
reason = str(parsed.get("reason", ""))
|
|
162
|
+
|
|
163
|
+
log_event("semantic_assert_llm", {
|
|
164
|
+
"actual": actual[:200],
|
|
165
|
+
"expected_intent": expected_intent[:200],
|
|
166
|
+
"threshold": threshold,
|
|
167
|
+
"score": score,
|
|
168
|
+
"passed": passed,
|
|
169
|
+
"reason": reason,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return SemanticResult(passed=passed, score=score, reason=reason, method="llm")
|
|
173
|
+
|
|
174
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
175
|
+
# Graceful degradation: fall back to local on parse error
|
|
176
|
+
log_event("semantic_assert_llm_parse_error", {"error": str(exc), "raw": raw[:300]})
|
|
177
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
178
|
+
result.reason = f"[LLM response unparseable, fell back to local] {result.reason}"
|
|
179
|
+
result.method = "local_fallback"
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
except LLMClientError as exc:
|
|
183
|
+
# No API key or network error — fall back to local silently
|
|
184
|
+
log_event("semantic_assert_llm_error", {"error": str(exc)})
|
|
185
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
186
|
+
result.reason = f"[LLM unavailable, fell back to local] {result.reason}"
|
|
187
|
+
result.method = "local_fallback"
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Public API
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def assert_semantic_match(
|
|
196
|
+
actual: str,
|
|
197
|
+
expected_intent: str,
|
|
198
|
+
use_llm: bool = False,
|
|
199
|
+
threshold: float = 0.75,
|
|
200
|
+
api_key: Optional[str] = None,
|
|
201
|
+
raise_on_failure: bool = True,
|
|
202
|
+
) -> SemanticResult:
|
|
203
|
+
"""
|
|
204
|
+
Assert that `actual` semantically matches `expected_intent`.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
actual: The string to validate (e.g. chatbot response, generated text).
|
|
208
|
+
expected_intent: Natural language description of what the output should convey.
|
|
209
|
+
use_llm: If True, use Groq for a more nuanced judgment.
|
|
210
|
+
Requires GROQ_API_KEY. Default: False (local cosine similarity).
|
|
211
|
+
threshold: Minimum score to pass. Range: [0.0, 1.0]. Default: 0.75.
|
|
212
|
+
api_key: Override GROQ_API_KEY env var (optional).
|
|
213
|
+
raise_on_failure: If True (default), raise SemanticAssertionError on failure.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
SemanticResult(passed, score, reason, method)
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
SemanticAssertionError: If the assertion fails and raise_on_failure=True.
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
# Local — zero cost, always available
|
|
223
|
+
assert_semantic_match(
|
|
224
|
+
actual="Great experience, delivered next day!",
|
|
225
|
+
expected_intent="positive customer review",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# LLM — richer judgment, requires API key
|
|
229
|
+
result = assert_semantic_match(
|
|
230
|
+
actual=model_output,
|
|
231
|
+
expected_intent="professional refusal of the request",
|
|
232
|
+
use_llm=True,
|
|
233
|
+
threshold=0.8,
|
|
234
|
+
)
|
|
235
|
+
print(result.score, result.reason)
|
|
236
|
+
"""
|
|
237
|
+
if not isinstance(actual, str) or not isinstance(expected_intent, str):
|
|
238
|
+
raise TypeError(
|
|
239
|
+
f"assert_semantic_match expects str arguments, "
|
|
240
|
+
f"got actual={type(actual).__name__}, "
|
|
241
|
+
f"expected_intent={type(expected_intent).__name__}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if use_llm:
|
|
245
|
+
result = _llm_assert(actual, expected_intent, threshold, api_key)
|
|
246
|
+
else:
|
|
247
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
248
|
+
|
|
249
|
+
if not result.passed and raise_on_failure:
|
|
250
|
+
raise SemanticAssertionError(result, actual, expected_intent)
|
|
251
|
+
|
|
252
|
+
return result
|
|
@@ -38,8 +38,14 @@ class BasicGenerator:
|
|
|
38
38
|
self.verbose = verbose
|
|
39
39
|
|
|
40
40
|
if seed is not None:
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# seed_instance() seeds THIS faker object's private RNG only —
|
|
42
|
+
# two Forge(seed=42) instances are fully independent.
|
|
43
|
+
self.faker.seed_instance(seed)
|
|
44
|
+
# Use a dedicated Random instance (not global random module)
|
|
45
|
+
# so two Forge objects with the same seed don't interfere.
|
|
46
|
+
self._rng = random.Random(seed)
|
|
47
|
+
else:
|
|
48
|
+
self._rng = random
|
|
43
49
|
|
|
44
50
|
if ai_engine is not None:
|
|
45
51
|
self.ai_engine = ai_engine
|
|
@@ -112,7 +118,7 @@ class BasicGenerator:
|
|
|
112
118
|
target_model = name[: -len("_id")]
|
|
113
119
|
records = self.registry.get(target_model, [])
|
|
114
120
|
if records:
|
|
115
|
-
return getattr(
|
|
121
|
+
return getattr(self._rng.choice(records), "id", None)
|
|
116
122
|
|
|
117
123
|
# Integer PK / sequential ID
|
|
118
124
|
if "int" in field.type_name.lower():
|
|
@@ -181,19 +187,19 @@ class BasicGenerator:
|
|
|
181
187
|
if "time" in name_parts:
|
|
182
188
|
return self.faker.time()
|
|
183
189
|
if "gender" in name_parts:
|
|
184
|
-
return
|
|
190
|
+
return self._rng.choice(["male", "female", "non-binary"])
|
|
185
191
|
if "age" in name_parts:
|
|
186
|
-
return
|
|
192
|
+
return self._rng.randint(18, 80)
|
|
187
193
|
if "salary" in name or "amount" in name or "price" in name:
|
|
188
|
-
return round(
|
|
194
|
+
return round(self._rng.uniform(10, 10_000), 2)
|
|
189
195
|
if "score" in name or "rating" in name:
|
|
190
|
-
return round(
|
|
196
|
+
return round(self._rng.uniform(1, 5), 1)
|
|
191
197
|
if "quantity" in name or "qty" in name:
|
|
192
|
-
return
|
|
198
|
+
return self._rng.randint(1, 100)
|
|
193
199
|
if "weight" in name_parts:
|
|
194
|
-
return round(
|
|
200
|
+
return round(self._rng.uniform(0.1, 500), 2)
|
|
195
201
|
if "height" in name_parts:
|
|
196
|
-
return round(
|
|
202
|
+
return round(self._rng.uniform(1.0, 2.5), 2)
|
|
197
203
|
|
|
198
204
|
# Type fallbacks
|
|
199
205
|
if "int" in type_name:
|
|
@@ -201,7 +207,7 @@ class BasicGenerator:
|
|
|
201
207
|
if "bool" in type_name:
|
|
202
208
|
return self.faker.boolean()
|
|
203
209
|
if "float" in type_name:
|
|
204
|
-
return round(
|
|
210
|
+
return round(self._rng.uniform(0, 1000), 2)
|
|
205
211
|
if "list" in type_name:
|
|
206
212
|
return []
|
|
207
213
|
if "dict" in type_name:
|
|
@@ -215,7 +221,7 @@ class BasicGenerator:
|
|
|
215
221
|
max_val = c.get("le", c.get("lt", 10001) - 1 if "lt" in c else 10000)
|
|
216
222
|
if min_val > max_val:
|
|
217
223
|
max_val = min_val + 100
|
|
218
|
-
return
|
|
224
|
+
return self._rng.randint(min_val, max_val)
|
|
219
225
|
|
|
220
226
|
|
|
221
227
|
# Sentinel object returned for COMPUTED fields so the generator knows to
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FixtureForge pytest plugin.
|
|
3
|
+
|
|
4
|
+
Provides two integration points:
|
|
5
|
+
|
|
6
|
+
1. ``forge`` fixture — auto-available in every test, zero config.
|
|
7
|
+
CI-safe by default (use_ai=False). Activated by FORGE_AI=1.
|
|
8
|
+
|
|
9
|
+
2. ``forge_fixture()`` — declare model fixtures in conftest.py with one line.
|
|
10
|
+
Injects a named pytest fixture into the caller's namespace automatically.
|
|
11
|
+
|
|
12
|
+
-------------------------------------------------------------------------------
|
|
13
|
+
QUICK START
|
|
14
|
+
-------------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
# conftest.py
|
|
17
|
+
from fixtureforge.pytest_plugin import forge_fixture
|
|
18
|
+
from myapp.models import User, Order, Product
|
|
19
|
+
|
|
20
|
+
# Declare fixtures — one line per model
|
|
21
|
+
forge_fixture(User, count=10, seed=42)
|
|
22
|
+
forge_fixture(Order, count=50, seed=42, context="e-commerce orders")
|
|
23
|
+
forge_fixture(Product, count=1) # singular — returns one instance
|
|
24
|
+
|
|
25
|
+
# test_users.py
|
|
26
|
+
def test_all_users_have_email(users): # ← 10 User instances
|
|
27
|
+
assert all("@" in u.email for u in users)
|
|
28
|
+
|
|
29
|
+
def test_order_links_customer(orders, users): # ← FK resolved automatically
|
|
30
|
+
assert all(o.user_id in {u.id for u in users} for o in orders)
|
|
31
|
+
|
|
32
|
+
def test_product_name(product): # ← single Product instance
|
|
33
|
+
assert len(product.name) > 0
|
|
34
|
+
|
|
35
|
+
-------------------------------------------------------------------------------
|
|
36
|
+
ADVANCED USAGE
|
|
37
|
+
-------------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Explicit forge fixture for one-off generation
|
|
40
|
+
def test_custom(forge):
|
|
41
|
+
vip = forge.create(User, context="VIP enterprise customer")
|
|
42
|
+
assert vip.name
|
|
43
|
+
|
|
44
|
+
# Full AI mode in dev (override with env var)
|
|
45
|
+
# FORGE_AI=1 pytest → uses real LLM
|
|
46
|
+
# FORGE_SEED=42 pytest → reproducible seed
|
|
47
|
+
|
|
48
|
+
# Scoped fixtures (shared across all tests in a module)
|
|
49
|
+
forge_fixture(User, count=100, seed=42, scope="module")
|
|
50
|
+
|
|
51
|
+
# Named override (when you want a different name than the model)
|
|
52
|
+
forge_fixture(User, count=5, seed=0, name="admin_users", context="admin accounts")
|
|
53
|
+
|
|
54
|
+
def test_admins(admin_users):
|
|
55
|
+
assert len(admin_users) == 5
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import os
|
|
60
|
+
import sys
|
|
61
|
+
from typing import Any, Dict, List, Optional, Type
|
|
62
|
+
|
|
63
|
+
import pytest
|
|
64
|
+
from pydantic import BaseModel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _resolve_forge_config() -> Dict[str, Any]:
|
|
72
|
+
"""Read Forge config from environment variables."""
|
|
73
|
+
use_ai = os.environ.get("FORGE_AI", "0").strip().lower() in ("1", "true", "yes")
|
|
74
|
+
seed_env = os.environ.get("FORGE_SEED", "").strip()
|
|
75
|
+
seed = int(seed_env) if seed_env.isdigit() else None
|
|
76
|
+
verbose = os.environ.get("FORGE_VERBOSE", "0").strip().lower() in ("1", "true", "yes")
|
|
77
|
+
return {"use_ai": use_ai, "seed": seed, "verbose": verbose}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fixture_name_for(model: Type[BaseModel], count: int, name: Optional[str]) -> str:
|
|
81
|
+
"""Derive fixture name: explicit name > plural/singular from model name."""
|
|
82
|
+
if name:
|
|
83
|
+
return name
|
|
84
|
+
base = model.__name__.lower()
|
|
85
|
+
return base + "s" if count > 1 else base
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Auto-provided ``forge`` fixture (available in every test, zero config)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@pytest.fixture
|
|
93
|
+
def forge():
|
|
94
|
+
"""
|
|
95
|
+
Auto-provided Forge instance.
|
|
96
|
+
|
|
97
|
+
CI-safe by default (use_ai=False, seed from FORGE_SEED env var).
|
|
98
|
+
Set FORGE_AI=1 to enable real AI generation in dev/staging.
|
|
99
|
+
|
|
100
|
+
Example::
|
|
101
|
+
|
|
102
|
+
def test_something(forge):
|
|
103
|
+
users = forge.create_batch(User, count=10)
|
|
104
|
+
assert len(users) == 10
|
|
105
|
+
"""
|
|
106
|
+
from fixtureforge import Forge # lazy import — keeps pytest startup fast
|
|
107
|
+
|
|
108
|
+
cfg = _resolve_forge_config()
|
|
109
|
+
return Forge(
|
|
110
|
+
use_ai=cfg["use_ai"],
|
|
111
|
+
seed=cfg["seed"],
|
|
112
|
+
verbose=cfg["verbose"],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# forge_fixture() — one-line fixture declaration
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def forge_fixture(
|
|
121
|
+
model: Type[BaseModel],
|
|
122
|
+
count: int = 1,
|
|
123
|
+
*,
|
|
124
|
+
context: Optional[str] = None,
|
|
125
|
+
seed: Optional[int] = None,
|
|
126
|
+
use_ai: Optional[bool] = None,
|
|
127
|
+
scope: str = "function",
|
|
128
|
+
name: Optional[str] = None,
|
|
129
|
+
**overrides: Any,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""
|
|
132
|
+
Declare a pytest fixture that generates model instances.
|
|
133
|
+
|
|
134
|
+
Call this at module level in ``conftest.py``. The fixture is injected
|
|
135
|
+
directly into the caller's namespace under the derived or given name.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
model : Pydantic BaseModel subclass to generate
|
|
140
|
+
count : number of records (>1 → list, ==1 → single instance)
|
|
141
|
+
context : AI prompt context, e.g. "angry customers"
|
|
142
|
+
seed : random seed for deterministic output (overrides FORGE_SEED)
|
|
143
|
+
use_ai : override env-based detection (default: False / FORGE_AI env var)
|
|
144
|
+
scope : pytest fixture scope — "function" | "class" | "module" | "session"
|
|
145
|
+
name : explicit fixture name (default: model name in lower-case, pluralised)
|
|
146
|
+
**overrides : field overrides forwarded to forge.create / create_batch
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
The pytest fixture callable (also injected into caller's namespace).
|
|
151
|
+
|
|
152
|
+
Example::
|
|
153
|
+
|
|
154
|
+
# conftest.py
|
|
155
|
+
forge_fixture(User, count=50, seed=42)
|
|
156
|
+
forge_fixture(Order, count=200, seed=42, context="high-value orders")
|
|
157
|
+
|
|
158
|
+
# test_orders.py
|
|
159
|
+
def test_order_count(orders):
|
|
160
|
+
assert len(orders) == 200
|
|
161
|
+
"""
|
|
162
|
+
fixture_name = _fixture_name_for(model, count, name)
|
|
163
|
+
|
|
164
|
+
# Resolve use_ai: explicit arg > env var
|
|
165
|
+
_use_ai: bool
|
|
166
|
+
if use_ai is not None:
|
|
167
|
+
_use_ai = use_ai
|
|
168
|
+
else:
|
|
169
|
+
_use_ai = os.environ.get("FORGE_AI", "0").strip().lower() in ("1", "true", "yes")
|
|
170
|
+
|
|
171
|
+
# Resolve seed: explicit arg > env var
|
|
172
|
+
_seed: Optional[int]
|
|
173
|
+
if seed is not None:
|
|
174
|
+
_seed = seed
|
|
175
|
+
else:
|
|
176
|
+
seed_env = os.environ.get("FORGE_SEED", "").strip()
|
|
177
|
+
_seed = int(seed_env) if seed_env.isdigit() else None
|
|
178
|
+
|
|
179
|
+
# Capture all params in closure (avoids late-binding issues in loops)
|
|
180
|
+
_model = model
|
|
181
|
+
_count = count
|
|
182
|
+
_context = context
|
|
183
|
+
_overrides = overrides
|
|
184
|
+
|
|
185
|
+
def _fixture_fn():
|
|
186
|
+
from fixtureforge import Forge # lazy import
|
|
187
|
+
|
|
188
|
+
f = Forge(use_ai=_use_ai, seed=_seed)
|
|
189
|
+
|
|
190
|
+
if _count == 1:
|
|
191
|
+
return f.create(_model, context=_context, **_overrides)
|
|
192
|
+
return f.create_batch(_model, count=_count, context=_context, **_overrides)
|
|
193
|
+
|
|
194
|
+
# Give the function the correct name so pytest identifies the fixture by it
|
|
195
|
+
_fixture_fn.__name__ = fixture_name
|
|
196
|
+
_fixture_fn.__qualname__ = fixture_name
|
|
197
|
+
|
|
198
|
+
# Apply the pytest.fixture decorator with the requested scope
|
|
199
|
+
marked = pytest.fixture(scope=scope, name=fixture_name)(_fixture_fn)
|
|
200
|
+
|
|
201
|
+
# Inject into caller's conftest.py namespace so pytest can discover it
|
|
202
|
+
frame = sys._getframe(1)
|
|
203
|
+
frame.f_locals[fixture_name] = marked
|
|
204
|
+
|
|
205
|
+
return marked
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# forge_swarm_fixture() — parallel multi-model fixture in one call
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def forge_swarm_fixture(
|
|
213
|
+
models: List[Type[BaseModel]],
|
|
214
|
+
counts: Optional[List[int]] = None,
|
|
215
|
+
contexts: Optional[List[Optional[str]]] = None,
|
|
216
|
+
*,
|
|
217
|
+
seed: Optional[int] = None,
|
|
218
|
+
use_ai: Optional[bool] = None,
|
|
219
|
+
scope: str = "session",
|
|
220
|
+
) -> Any:
|
|
221
|
+
"""
|
|
222
|
+
Declare a single fixture that generates multiple models in parallel
|
|
223
|
+
using DataSwarms. Returns a dict mapping model_name → list.
|
|
224
|
+
|
|
225
|
+
Best used with ``scope="session"`` so the swarm runs once per test run.
|
|
226
|
+
|
|
227
|
+
Example::
|
|
228
|
+
|
|
229
|
+
# conftest.py
|
|
230
|
+
forge_swarm_fixture(
|
|
231
|
+
[User, Order, Product],
|
|
232
|
+
counts=[10, 50, 100],
|
|
233
|
+
seed=42,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# test_something.py
|
|
237
|
+
def test_data_volume(swarm_data):
|
|
238
|
+
assert len(swarm_data["User"]) == 10
|
|
239
|
+
assert len(swarm_data["Order"]) == 50
|
|
240
|
+
"""
|
|
241
|
+
_use_ai = (
|
|
242
|
+
use_ai
|
|
243
|
+
if use_ai is not None
|
|
244
|
+
else os.environ.get("FORGE_AI", "0").strip().lower() in ("1", "true", "yes")
|
|
245
|
+
)
|
|
246
|
+
_seed = seed or (
|
|
247
|
+
int(s) if (s := os.environ.get("FORGE_SEED", "").strip()).isdigit() else None
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _swarm_fn():
|
|
251
|
+
from fixtureforge import Forge
|
|
252
|
+
|
|
253
|
+
f = Forge(use_ai=_use_ai, seed=_seed)
|
|
254
|
+
return f.swarm(models=models, counts=counts, contexts=contexts)
|
|
255
|
+
|
|
256
|
+
_swarm_fn.__name__ = "swarm_data"
|
|
257
|
+
_swarm_fn.__qualname__ = "swarm_data"
|
|
258
|
+
|
|
259
|
+
marked = pytest.fixture(scope=scope, name="swarm_data")(_swarm_fn)
|
|
260
|
+
|
|
261
|
+
frame = sys._getframe(1)
|
|
262
|
+
frame.f_locals["swarm_data"] = marked
|
|
263
|
+
|
|
264
|
+
return marked
|
|
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
|