kryptorious-testforge 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: kryptorious-testforge
3
+ Version: 1.0.0
4
+ Summary: Automated pytest test generation from Python source — generate, coverage, fixtures, mocks.
5
+ Author: Kryptorious Quantum Biosciences, Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://devflow.sh
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: click>=8.0
11
+ Requires-Dist: rich>=13.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: black>=23.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.1; extra == "dev"
@@ -0,0 +1,8 @@
1
+ testforge/__init__.py,sha256=DVqisK3f2FgjzLurn3IHgCK-D4gCi6aMGifv4FFj6Ms,76
2
+ testforge/cli.py,sha256=r_nPd6TEegNA96CkVcCuWc9x-gt2PD6jHJHsjJ708os,5858
3
+ testforge/generator.py,sha256=j57I2-SXvYj30WvvDMxEa-gyQA11wabCVmpc3j3RkHQ,13282
4
+ kryptorious_testforge-1.0.0.dist-info/METADATA,sha256=DOWRLEKeR8s11o2O16spsoCJZeaAHx0PLtqNK2o6puQ,545
5
+ kryptorious_testforge-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ kryptorious_testforge-1.0.0.dist-info/entry_points.txt,sha256=gIoZjP3QOB_zdPOeULSI2M6g1bGbYx9p6xhTDpMHlYM,49
7
+ kryptorious_testforge-1.0.0.dist-info/top_level.txt,sha256=YlnAe86SwUHL4RQ8lflp82pzevO6eAJyj1IbGkSWRnw,10
8
+ kryptorious_testforge-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ testforge = testforge.cli:main
@@ -0,0 +1 @@
1
+ testforge
testforge/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """TestForge — Automated pytest test generation."""
2
+ __version__ = "1.0.0"
testforge/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """TestForge CLI — Automated pytest test generation."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.syntax import Syntax
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(version="1.0.0", prog_name="testforge")
16
+ def main():
17
+ """TestForge — Generate pytest tests from Python source code.
18
+
19
+ Point it at any .py file and get a complete test suite.
20
+ Stop writing boilerplate. Start testing.
21
+ """
22
+ pass
23
+
24
+
25
+ @main.command()
26
+ @click.argument("source", type=click.Path(exists=True))
27
+ @click.option("--output", "-o", default=None, help="Output path (default: test_<source>)")
28
+ @click.option("--preview/--write", default=True, help="Preview tests before writing")
29
+ def generate(source, output, preview):
30
+ """Generate pytest tests from a Python source file.
31
+
32
+ \b
33
+ Examples:
34
+ testforge generate my_module.py
35
+ testforge generate src/app.py -o tests/test_app.py
36
+ testforge generate utils.py --preview
37
+ """
38
+ from .generator import generate_tests
39
+
40
+ source_path = Path(source)
41
+ console.print()
42
+ console.print(Panel(
43
+ f"[bold]TestForge — Generate[/bold] tests for [cyan]{source_path.name}[/cyan]",
44
+ border_style="blue"
45
+ ))
46
+
47
+ try:
48
+ test_code = generate_tests(str(source_path), output_path=output if not preview else None)
49
+ except Exception as e:
50
+ console.print(f"[red]Error:[/red] {e}")
51
+ return
52
+
53
+ # Count generated tests
54
+ test_count = test_code.count("def test_")
55
+ class_count = test_code.count("class Test")
56
+
57
+ if preview:
58
+ console.print()
59
+ console.print(Syntax(test_code, "python", theme="monokai", line_numbers=True))
60
+ console.print()
61
+ console.print(f"[green]Preview:[/green] {test_count} test(s), {class_count} test class(es)")
62
+ console.print()
63
+ console.print("[dim]Run with --write to save.[/dim]")
64
+ else:
65
+ out = output or f"test_{source_path.name}"
66
+ console.print(f"[green]Generated[/green] → {out}")
67
+ console.print(f" {test_count} test function(s)")
68
+ console.print(f" {class_count} test class(es)")
69
+
70
+
71
+ @main.command()
72
+ @click.argument("source", type=click.Path(exists=True))
73
+ def coverage(source):
74
+ """Analyze which functions lack tests.
75
+
76
+ \b
77
+ Example:
78
+ testforge coverage my_module.py
79
+ """
80
+ from .generator import analyze_coverage
81
+
82
+ source_path = Path(source)
83
+ console.print()
84
+ console.print(Panel(
85
+ f"[bold]TestForge — Coverage[/bold] for [cyan]{source_path.name}[/cyan]",
86
+ border_style="blue"
87
+ ))
88
+
89
+ try:
90
+ result = analyze_coverage(str(source_path))
91
+ except Exception as e:
92
+ console.print(f"[red]Error:[/red] {e}")
93
+ return
94
+
95
+ pct = result["coverage_pct"]
96
+ color = "green" if pct >= 80 else "yellow" if pct >= 50 else "red"
97
+
98
+ console.print(f"\n[bold {color}]{pct}%[/bold {color}] coverage — "
99
+ f"{len(result['tested'])} tested, {len(result['untested'])} untested")
100
+
101
+ if result["untested"]:
102
+ console.print()
103
+ console.print("[yellow]Untested functions:[/yellow]")
104
+ for func in result["untested"]:
105
+ console.print(f" • [cyan]{func}[/cyan]()")
106
+ console.print()
107
+ console.print("[dim]Run 'testforge generate' to create tests for these.[/dim]")
108
+
109
+ if result["tested"]:
110
+ console.print()
111
+ console.print("[green]Already tested:[/green]")
112
+ for func in result["tested"]:
113
+ console.print(f" • [green]✓[/green] {func}()")
114
+
115
+
116
+ @main.command()
117
+ @click.argument("path", type=click.Path(exists=True), default=".")
118
+ @click.option("--output", "-o", default=None, help="Output path for conftest.py")
119
+ def fixtures(path, output):
120
+ """Generate conftest.py with common pytest fixtures.
121
+
122
+ \b
123
+ Example:
124
+ testforge fixtures
125
+ testforge fixtures tests/ -o tests/conftest.py
126
+ """
127
+ from .generator import generate_fixtures
128
+
129
+ console.print()
130
+ console.print(Panel(
131
+ f"[bold]TestForge — Fixtures[/bold] for [cyan]{path}[/cyan]",
132
+ border_style="blue"
133
+ ))
134
+
135
+ try:
136
+ result = generate_fixtures(path, output_path=output)
137
+ except Exception as e:
138
+ console.print(f"[red]Error:[/red] {e}")
139
+ return
140
+
141
+ out_path = output or f"{path}/conftest.py"
142
+ console.print(f"[green]Generated[/green] → {out_path}")
143
+ fixture_count = result.count("@pytest.fixture")
144
+ console.print(f" {fixture_count} fixture(s)")
145
+
146
+
147
+ @main.command()
148
+ @click.argument("source", type=click.Path(exists=True))
149
+ def mock(source):
150
+ """Generate mock objects for dependencies (premium).
151
+
152
+ \b
153
+ Example:
154
+ testforge mock my_module.py
155
+ """
156
+ console.print()
157
+ console.print("[yellow]Mock generation is a premium feature.[/yellow]")
158
+ console.print("Upgrade at https://kryptorious.gumroad.com/l/jbvet")
159
+ console.print()
160
+ console.print(f"[dim]Would generate mocks for imports in {source}[/dim]")
161
+
162
+
163
+ @main.command()
164
+ @click.argument("path", type=click.Path(exists=True), default=".")
165
+ def suite(path):
166
+ """Generate a full test suite from an entire directory (premium).
167
+
168
+ \b
169
+ Example:
170
+ testforge suite src/
171
+ """
172
+ console.print()
173
+ console.print("[yellow]Full suite generation is a premium feature.[/yellow]")
174
+ console.print("Upgrade at https://kryptorious.gumroad.com/l/jbvet")
175
+ console.print()
176
+ console.print(f"[dim]Would generate tests for all .py files in {path}[/dim]")
177
+ console.print()
178
+ console.print("[bold]Free tip:[/bold] Run testforge generate on individual files:")
179
+ source_files = list(Path(path).rglob("*.py"))
180
+ for sf in source_files[:5]:
181
+ if not sf.name.startswith("_") and "test" not in sf.name:
182
+ console.print(f" testforge generate {sf}")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
testforge/generator.py ADDED
@@ -0,0 +1,364 @@
1
+ """TestForge — Test generation engine.
2
+
3
+ Parses Python source files and generates pytest test functions.
4
+ """
5
+
6
+ import ast
7
+ import inspect
8
+ import re
9
+ from pathlib import Path
10
+ from typing import List, Dict, Any, Optional
11
+
12
+
13
+ def generate_tests(source_path: str, output_path: Optional[str] = None) -> str:
14
+ """Generate pytest tests from a Python source file.
15
+
16
+ Args:
17
+ source_path: Path to .py file to analyze
18
+ output_path: Where to write generated tests (auto-derived if None)
19
+
20
+ Returns:
21
+ Generated test code as string
22
+ """
23
+ source = Path(source_path)
24
+ if not source.exists():
25
+ raise FileNotFoundError(f"Source file not found: {source_path}")
26
+
27
+ code = source.read_text(encoding="utf-8")
28
+ tree = ast.parse(code)
29
+
30
+ module_name = source.stem
31
+ functions = _extract_functions(tree)
32
+ classes = _extract_classes(tree)
33
+
34
+ output = _build_test_module(module_name, functions, classes, code)
35
+
36
+ if output_path:
37
+ Path(output_path).write_text(output, encoding="utf-8")
38
+ else:
39
+ default_path = source.parent / f"test_{source.name}"
40
+ default_path.write_text(output, encoding="utf-8")
41
+
42
+ return output
43
+
44
+
45
+ def _extract_functions(tree: ast.Module) -> List[Dict[str, Any]]:
46
+ """Extract top-level function definitions from AST."""
47
+ functions = []
48
+ for node in ast.iter_child_nodes(tree):
49
+ if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
50
+ func = {
51
+ "name": node.name,
52
+ "args": [],
53
+ "returns": None,
54
+ "has_docstring": (ast.get_docstring(node) is not None),
55
+ "async": isinstance(node, ast.AsyncFunctionDef),
56
+ "decorators": [d.id for d in node.decorator_list if isinstance(d, ast.Name)],
57
+ "lineno": node.lineno,
58
+ }
59
+ for arg in node.args.args:
60
+ arg_info = {"name": arg.arg}
61
+ if arg.annotation:
62
+ arg_info["type"] = ast.unparse(arg.annotation)
63
+ func["args"].append(arg_info)
64
+ if node.returns:
65
+ func["returns"] = ast.unparse(node.returns)
66
+ functions.append(func)
67
+ return functions
68
+
69
+
70
+ def _extract_classes(tree: ast.Module) -> List[Dict[str, Any]]:
71
+ """Extract class definitions with their methods."""
72
+ classes = []
73
+ for node in ast.iter_child_nodes(tree):
74
+ if isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
75
+ cls = {
76
+ "name": node.name,
77
+ "methods": [],
78
+ "has_docstring": (ast.get_docstring(node) is not None),
79
+ "bases": [ast.unparse(b) for b in node.bases],
80
+ "lineno": node.lineno,
81
+ }
82
+ for item in node.body:
83
+ if isinstance(item, ast.FunctionDef) and not item.name.startswith("_"):
84
+ method = {
85
+ "name": item.name,
86
+ "args": [],
87
+ "returns": None,
88
+ "has_docstring": (ast.get_docstring(item) is not None),
89
+ "async": isinstance(item, ast.AsyncFunctionDef),
90
+ }
91
+ for arg in item.args.args:
92
+ if arg.arg == "self":
93
+ continue
94
+ arg_info = {"name": arg.arg}
95
+ if arg.annotation:
96
+ arg_info["type"] = ast.unparse(arg.annotation)
97
+ method["args"].append(arg_info)
98
+ if item.returns:
99
+ method["returns"] = ast.unparse(item.returns)
100
+ cls["methods"].append(method)
101
+ classes.append(cls)
102
+ return classes
103
+
104
+
105
+ def _build_test_module(module_name: str, functions: List[Dict],
106
+ classes: List[Dict], source_code: str) -> str:
107
+ """Build complete test module from extracted info."""
108
+ lines = []
109
+ lines.append(f'"""Auto-generated tests for {module_name}.py — generated by TestForge."""')
110
+ lines.append("import pytest")
111
+ lines.append(f"from {module_name} import (")
112
+ # Import all public functions and classes
113
+ imports = [f["name"] for f in functions]
114
+ imports.extend(c["name"] for c in classes)
115
+ for imp in imports:
116
+ lines.append(f" {imp},")
117
+ lines.append(")")
118
+ lines.append("")
119
+ lines.append("")
120
+
121
+ # Generate test for each function
122
+ for func in functions:
123
+ lines.extend(_generate_function_test(module_name, func))
124
+
125
+ # Generate test class for each class
126
+ for cls in classes:
127
+ lines.extend(_generate_class_tests(module_name, cls))
128
+
129
+ return "\n".join(lines)
130
+
131
+
132
+ def _generate_function_test(module: str, func: Dict) -> List[str]:
133
+ """Generate test function for a single function."""
134
+ lines = []
135
+ name = func["name"]
136
+ args = func["args"]
137
+ returns = func.get("returns")
138
+ async_prefix = "async " if func.get("async") else ""
139
+ await_prefix = "await " if func.get("async") else ""
140
+
141
+ # Test: callable exists
142
+ lines.append(f"def test_{name}_exists():")
143
+ lines.append(f' """Verify {name}() is importable and callable."""')
144
+ lines.append(f" assert callable({name})")
145
+ lines.append("")
146
+
147
+ # Test: basic call if no required args or all have defaults
148
+ required_args = [a for a in args if "=" not in str(a.get("default", ""))]
149
+ if not required_args:
150
+ lines.append(f"{async_prefix}def test_{name}_basic_call():")
151
+ lines.append(f' """Test basic call to {name}()."""')
152
+ if returns and returns != "None":
153
+ lines.append(f" result = {await_prefix}{name}()")
154
+ lines.append(f" assert result is not None, '{name}() returned None'")
155
+ else:
156
+ lines.append(f" try:")
157
+ lines.append(f" {await_prefix}{name}()")
158
+ lines.append(f" except Exception as e:")
159
+ lines.append(f' pytest.fail(f"{name}() raised {{e}}")')
160
+ lines.append("")
161
+
162
+ # Test: with arguments if they exist
163
+ if args:
164
+ arg_names = [a["name"] for a in args]
165
+ call_args = ", ".join(_generate_arg_values(a) for a in args)
166
+ lines.append(f"{async_prefix}def test_{name}_with_args():")
167
+ lines.append(f' """Test {name}() with sample arguments."""')
168
+ lines.append(f" try:")
169
+ lines.append(f" result = {await_prefix}{name}({call_args})")
170
+ if returns and returns != "None":
171
+ lines.append(f" assert result is not None, '{name}() returned None with args'")
172
+ lines.append(f" except TypeError:")
173
+ lines.append(f" pytest.skip('Could not determine valid arguments')")
174
+ lines.append(f" except Exception as e:")
175
+ lines.append(f' pytest.fail(f"{name}() raised {{e}} with args")')
176
+ lines.append("")
177
+
178
+ # Test: type hints
179
+ if returns and returns != "None":
180
+ lines.append(f"{async_prefix}def test_{name}_return_type():")
181
+ lines.append(f' """Verify {name}() return type matches annotation."""')
182
+ if not required_args:
183
+ lines.append(f" result = {await_prefix}{name}()")
184
+ lines.append(f" # Annotated return type: {returns}")
185
+ lines.append(f" assert result is not None")
186
+ else:
187
+ lines.append(f" pytest.skip('Requires arguments to test return type')")
188
+ lines.append("")
189
+
190
+ return lines
191
+
192
+
193
+ def _generate_class_tests(module: str, cls: Dict) -> List[str]:
194
+ """Generate test class for a single class."""
195
+ lines = []
196
+ class_name = cls["name"]
197
+ methods = cls.get("methods", [])
198
+
199
+ lines.append(f"class Test{class_name}:")
200
+ lines.append(f' """Tests for {class_name}."""')
201
+ lines.append("")
202
+
203
+ # Test: instantiation
204
+ init_args = ""
205
+ for method in methods:
206
+ if method["name"] == "__init__":
207
+ init_args_list = method.get("args", [])
208
+ init_args = ", ".join(_generate_arg_values(a) for a in init_args_list)
209
+ break
210
+
211
+ lines.append(f" def test_instantiation(self):")
212
+ lines.append(f' """Verify {class_name} can be instantiated."""')
213
+ if init_args:
214
+ lines.append(f" try:")
215
+ lines.append(f" obj = {class_name}({init_args})")
216
+ lines.append(f" assert obj is not None")
217
+ lines.append(f" except TypeError:")
218
+ lines.append(f" pytest.skip('Could not determine valid __init__ arguments')")
219
+ else:
220
+ lines.append(f" obj = {class_name}()")
221
+ lines.append(f" assert obj is not None")
222
+ lines.append("")
223
+
224
+ # Test each method
225
+ for method in methods:
226
+ if method["name"] == "__init__":
227
+ continue
228
+ mname = method["name"]
229
+ margs = method.get("args", [])
230
+ mreturns = method.get("returns")
231
+
232
+ lines.append(f" def test_{mname}(self):")
233
+ lines.append(f' """Test {class_name}.{mname}()."""')
234
+
235
+ if init_args:
236
+ lines.append(f" try:")
237
+ lines.append(f" obj = {class_name}({init_args})")
238
+ else:
239
+ lines.append(f" obj = {class_name}()")
240
+
241
+ if margs:
242
+ call_args = ", ".join(_generate_arg_values(a) for a in margs)
243
+ lines.append(f" result = obj.{mname}({call_args})")
244
+ else:
245
+ lines.append(f" result = obj.{mname}()")
246
+
247
+ if mreturns and mreturns != "None":
248
+ lines.append(f" assert result is not None, '{class_name}.{mname}() returned None'")
249
+
250
+ if init_args:
251
+ lines.append(f" except TypeError:")
252
+ lines.append(f" pytest.skip('Could not determine valid arguments')")
253
+
254
+ lines.append("")
255
+
256
+ return lines
257
+
258
+
259
+ def _generate_arg_values(arg: Dict) -> str:
260
+ """Generate sensible test values based on argument type annotation."""
261
+ name = arg.get("name", "arg")
262
+ arg_type = arg.get("type", "").lower()
263
+
264
+ type_map = {
265
+ "str": f'"{name}_test"',
266
+ "int": "42",
267
+ "float": "3.14",
268
+ "bool": "True",
269
+ "list": "[]",
270
+ "dict": "{}",
271
+ "tuple": "()",
272
+ "set": "set()",
273
+ "bytes": 'b"test"',
274
+ "none": "None",
275
+ }
276
+
277
+ for key, val in type_map.items():
278
+ if key in arg_type:
279
+ return val
280
+
281
+ # Default: use a type-appropriate placeholder
282
+ return f'"test_{name}"'
283
+
284
+
285
+ def analyze_coverage(source_path: str) -> Dict:
286
+ """Analyze which functions lack tests."""
287
+ source = Path(source_path)
288
+ module_name = source.stem
289
+
290
+ # Find existing test file
291
+ test_path = source.parent / f"test_{source.name}"
292
+ if not test_path.exists():
293
+ test_path = Path("tests") / f"test_{source.name}"
294
+ if not test_path.exists():
295
+ return {"tested": [], "untested": [], "coverage_pct": 0}
296
+
297
+ test_code = test_path.read_text(encoding="utf-8")
298
+ test_tree = ast.parse(test_code)
299
+
300
+ # Get tested function names
301
+ tested = set()
302
+ for node in ast.walk(test_tree):
303
+ if isinstance(node, ast.Call):
304
+ if isinstance(node.func, ast.Name):
305
+ tested.add(node.func.id)
306
+
307
+ source_tree = ast.parse(source.read_text(encoding="utf-8"))
308
+ all_funcs = {f["name"] for f in _extract_functions(source_tree)}
309
+
310
+ untested = all_funcs - tested
311
+ total = len(all_funcs)
312
+ covered = len(all_funcs - untested)
313
+ pct = int(covered / total * 100) if total > 0 else 100
314
+
315
+ return {
316
+ "tested": sorted(all_funcs - untested),
317
+ "untested": sorted(untested),
318
+ "coverage_pct": pct,
319
+ }
320
+
321
+
322
+ def generate_fixtures(source_path: str, output_path: Optional[str] = None) -> str:
323
+ """Generate a conftest.py with common fixtures."""
324
+ source = Path(source_path).resolve()
325
+ target_dir = source.parent if source.is_file() else source
326
+
327
+ fixtures = [
328
+ ("temp_dir", "tmp_path", "A temporary directory for file operations."),
329
+ ("sample_data", "dict", "Sample data for testing."),
330
+ ("mock_env", "None", "Mock environment variables."),
331
+ ]
332
+
333
+ lines = ['"""Auto-generated fixtures — generated by TestForge."""']
334
+ lines.append("import pytest")
335
+ lines.append("import tempfile")
336
+ lines.append("import os")
337
+ lines.append("from pathlib import Path")
338
+ lines.append("")
339
+ lines.append("")
340
+
341
+ lines.append("@pytest.fixture")
342
+ lines.append("def temp_dir(tmp_path):")
343
+ lines.append(' """A temporary directory for file operations."""')
344
+ lines.append(" return tmp_path")
345
+ lines.append("")
346
+
347
+ lines.append("@pytest.fixture")
348
+ lines.append("def sample_data():")
349
+ lines.append(' """Sample data dictionary for testing."""')
350
+ lines.append(' return {"name": "test", "value": 42, "items": [1, 2, 3]}')
351
+ lines.append("")
352
+
353
+ lines.append("@pytest.fixture")
354
+ lines.append("def mock_env(monkeypatch):")
355
+ lines.append(' """Mock environment variables for testing."""')
356
+ lines.append(' monkeypatch.setenv("TEST_MODE", "true")')
357
+ lines.append(' monkeypatch.setenv("DB_HOST", "localhost")')
358
+ lines.append("")
359
+
360
+ output = "\n".join(lines)
361
+
362
+ out_path = Path(output_path) if output_path else target_dir / "conftest.py"
363
+ out_path.write_text(output, encoding="utf-8")
364
+ return output