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.
Files changed (51) hide show
  1. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/PKG-INFO +8 -2
  2. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/README.md +7 -0
  3. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/pyproject.toml +9 -2
  4. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/__init__.py +1 -1
  5. fixtureforge-2.2.0/src/fixtureforge/ai/__init__.py +25 -0
  6. fixtureforge-2.2.0/src/fixtureforge/ai/_audit_log.py +44 -0
  7. fixtureforge-2.2.0/src/fixtureforge/ai/_groq_client.py +109 -0
  8. fixtureforge-2.2.0/src/fixtureforge/ai/failure_analyzer.py +253 -0
  9. fixtureforge-2.2.0/src/fixtureforge/ai/semantic_assert.py +252 -0
  10. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/generator.py +18 -12
  11. fixtureforge-2.2.0/src/fixtureforge/pytest_plugin.py +264 -0
  12. fixtureforge-2.0.2/src/fixtureforge/ai/__init__.py +0 -10
  13. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/LICENSE +0 -0
  14. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/cache.py +0 -0
  15. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/engine.py +0 -0
  16. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/ai/prompts.py +0 -0
  17. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/cli/__init__.py +0 -0
  18. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/cli/commands.py +0 -0
  19. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/config/__init__.py +0 -0
  20. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/config/flags.py +0 -0
  21. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/__init__.py +0 -0
  22. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/analyzer.py +0 -0
  23. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/batch_engine.py +0 -0
  24. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/compression.py +0 -0
  25. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/dataset.py +0 -0
  26. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/exporter.py +0 -0
  27. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/parser.py +0 -0
  28. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/recipe.py +0 -0
  29. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/router.py +0 -0
  30. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/streamer.py +0 -0
  31. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/core/swarm.py +0 -0
  32. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/__init__.py +0 -0
  33. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/github.py +0 -0
  34. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/integrations/jira.py +0 -0
  35. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/__init__.py +0 -0
  36. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/sharing.py +0 -0
  37. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/library/storage.py +0 -0
  38. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/__init__.py +0 -0
  39. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/dream.py +0 -0
  40. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/memory/store.py +0 -0
  41. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/__init__.py +0 -0
  42. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/anthropic.py +0 -0
  43. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/base.py +0 -0
  44. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/factory.py +0 -0
  45. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/gemini.py +0 -0
  46. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/groq.py +0 -0
  47. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/ollama.py +0 -0
  48. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/providers/openai.py +0 -0
  49. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/pyproject.toml +0 -0
  50. {fixtureforge-2.0.2 → fixtureforge-2.2.0}/src/fixtureforge/security/__init__.py +0 -0
  51. {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.2
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: testing,fixtures,test-data,qa,automation,synthetic-data,llm
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.2"
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 = ["testing", "fixtures", "test-data", "qa", "automation", "synthetic-data", "llm"]
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"
@@ -59,7 +59,7 @@ from .security.permissions import (
59
59
  ForgeCoordinator,
60
60
  )
61
61
 
62
- __version__ = "2.0.2"
62
+ __version__ = "2.1.0"
63
63
 
64
64
  T = TypeVar("T", bound=BaseModel)
65
65
 
@@ -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
- Faker.seed(seed)
42
- random.seed(seed)
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(random.choice(records), "id", None)
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 random.choice(["male", "female", "non-binary"])
190
+ return self._rng.choice(["male", "female", "non-binary"])
185
191
  if "age" in name_parts:
186
- return random.randint(18, 80)
192
+ return self._rng.randint(18, 80)
187
193
  if "salary" in name or "amount" in name or "price" in name:
188
- return round(random.uniform(10, 10_000), 2)
194
+ return round(self._rng.uniform(10, 10_000), 2)
189
195
  if "score" in name or "rating" in name:
190
- return round(random.uniform(1, 5), 1)
196
+ return round(self._rng.uniform(1, 5), 1)
191
197
  if "quantity" in name or "qty" in name:
192
- return random.randint(1, 100)
198
+ return self._rng.randint(1, 100)
193
199
  if "weight" in name_parts:
194
- return round(random.uniform(0.1, 500), 2)
200
+ return round(self._rng.uniform(0.1, 500), 2)
195
201
  if "height" in name_parts:
196
- return round(random.uniform(1.0, 2.5), 2)
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(random.uniform(0, 1000), 2)
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 random.randint(min_val, max_val)
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
@@ -1,10 +0,0 @@
1
- """
2
- AI-powered generation
3
- """
4
- from .engine import AIEngine
5
- from .cache import ResponseCache
6
-
7
- __all__ = [
8
- "AIEngine",
9
- "ResponseCache",
10
- ]
File without changes