invar-tools 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.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/core/parser.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST parser for extracting symbols and contracts.
|
|
3
|
+
|
|
4
|
+
This module receives string content (not file paths) and returns
|
|
5
|
+
structured data. No I/O operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
|
|
12
|
+
from deal import post, pre
|
|
13
|
+
|
|
14
|
+
from invar.core.models import Contract, FileInfo, Symbol, SymbolKind
|
|
15
|
+
from invar.core.purity import (
|
|
16
|
+
count_code_lines,
|
|
17
|
+
count_doctest_lines,
|
|
18
|
+
extract_function_calls,
|
|
19
|
+
extract_impure_calls,
|
|
20
|
+
extract_internal_imports,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pre(lambda source, path="<string>": isinstance(source, str) and len(source) > 0)
|
|
25
|
+
def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
|
|
26
|
+
"""
|
|
27
|
+
Parse Python source code and extract symbols.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
source: Python source code as string
|
|
31
|
+
path: Path for reporting (not used for I/O)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
FileInfo with extracted symbols, or None if syntax error
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> info = parse_source("def foo(): pass")
|
|
38
|
+
>>> info is not None
|
|
39
|
+
True
|
|
40
|
+
>>> len(info.symbols)
|
|
41
|
+
1
|
|
42
|
+
>>> info.symbols[0].name
|
|
43
|
+
'foo'
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
tree = ast.parse(source)
|
|
47
|
+
except (SyntaxError, TypeError, ValueError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
lines = source.count("\n") + 1
|
|
51
|
+
symbols = _extract_symbols(tree)
|
|
52
|
+
imports = _extract_imports(tree)
|
|
53
|
+
|
|
54
|
+
return FileInfo(
|
|
55
|
+
path=path,
|
|
56
|
+
lines=lines,
|
|
57
|
+
symbols=symbols,
|
|
58
|
+
imports=imports,
|
|
59
|
+
source=source,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
64
|
+
def _extract_symbols(tree: ast.Module) -> list[Symbol]:
|
|
65
|
+
"""
|
|
66
|
+
Extract function, class, and method symbols from AST.
|
|
67
|
+
|
|
68
|
+
Examples:
|
|
69
|
+
>>> import ast
|
|
70
|
+
>>> tree = ast.parse("class Foo:\\n def bar(self): pass")
|
|
71
|
+
>>> symbols = _extract_symbols(tree)
|
|
72
|
+
>>> len(symbols)
|
|
73
|
+
2
|
|
74
|
+
>>> symbols[0].kind.value
|
|
75
|
+
'class'
|
|
76
|
+
>>> symbols[1].kind.value
|
|
77
|
+
'method'
|
|
78
|
+
"""
|
|
79
|
+
symbols: list[Symbol] = []
|
|
80
|
+
|
|
81
|
+
for node in tree.body:
|
|
82
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
83
|
+
symbols.append(_parse_function(node))
|
|
84
|
+
elif isinstance(node, ast.ClassDef):
|
|
85
|
+
symbols.append(_parse_class(node))
|
|
86
|
+
# Extract methods from class body
|
|
87
|
+
for item in node.body:
|
|
88
|
+
if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
89
|
+
symbols.append(_parse_method(item, node.name))
|
|
90
|
+
|
|
91
|
+
return symbols
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pre(lambda node: (
|
|
95
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
|
|
96
|
+
hasattr(node, 'name') and hasattr(node, 'args') and hasattr(node, 'lineno')
|
|
97
|
+
))
|
|
98
|
+
@post(lambda result: result.kind == SymbolKind.FUNCTION)
|
|
99
|
+
def _parse_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Symbol:
|
|
100
|
+
"""Parse a function definition into a Symbol."""
|
|
101
|
+
contracts = _extract_contracts(node)
|
|
102
|
+
docstring = ast.get_docstring(node)
|
|
103
|
+
has_doctest = docstring is not None and ">>>" in docstring
|
|
104
|
+
signature = _build_signature(node)
|
|
105
|
+
internal_imports = extract_internal_imports(node)
|
|
106
|
+
impure_calls = extract_impure_calls(node)
|
|
107
|
+
code_lines = count_code_lines(node)
|
|
108
|
+
doctest_lines = count_doctest_lines(node)
|
|
109
|
+
function_calls = extract_function_calls(node) # P25
|
|
110
|
+
|
|
111
|
+
return Symbol(
|
|
112
|
+
name=node.name,
|
|
113
|
+
kind=SymbolKind.FUNCTION,
|
|
114
|
+
line=node.lineno,
|
|
115
|
+
end_line=node.end_lineno or node.lineno,
|
|
116
|
+
signature=signature,
|
|
117
|
+
docstring=docstring,
|
|
118
|
+
contracts=contracts,
|
|
119
|
+
has_doctest=has_doctest,
|
|
120
|
+
internal_imports=internal_imports,
|
|
121
|
+
impure_calls=impure_calls,
|
|
122
|
+
code_lines=code_lines,
|
|
123
|
+
doctest_lines=doctest_lines,
|
|
124
|
+
function_calls=function_calls,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pre(lambda node, class_name: (
|
|
129
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
|
|
130
|
+
hasattr(node, 'name') and hasattr(node, 'args') and hasattr(node, 'lineno')
|
|
131
|
+
))
|
|
132
|
+
@post(lambda result: result.kind == SymbolKind.METHOD)
|
|
133
|
+
def _parse_method(node: ast.FunctionDef | ast.AsyncFunctionDef, class_name: str) -> Symbol:
|
|
134
|
+
"""
|
|
135
|
+
Parse a method definition into a Symbol.
|
|
136
|
+
|
|
137
|
+
Examples:
|
|
138
|
+
>>> import ast
|
|
139
|
+
>>> tree = ast.parse("class Foo:\\n def bar(self): pass")
|
|
140
|
+
>>> method_node = tree.body[0].body[0]
|
|
141
|
+
>>> sym = _parse_method(method_node, "Foo")
|
|
142
|
+
>>> sym.name
|
|
143
|
+
'Foo.bar'
|
|
144
|
+
>>> sym.kind.value
|
|
145
|
+
'method'
|
|
146
|
+
"""
|
|
147
|
+
contracts = _extract_contracts(node)
|
|
148
|
+
docstring = ast.get_docstring(node)
|
|
149
|
+
has_doctest = docstring is not None and ">>>" in docstring
|
|
150
|
+
signature = _build_signature(node)
|
|
151
|
+
internal_imports = extract_internal_imports(node)
|
|
152
|
+
impure_calls = extract_impure_calls(node)
|
|
153
|
+
code_lines = count_code_lines(node)
|
|
154
|
+
doctest_lines = count_doctest_lines(node)
|
|
155
|
+
function_calls = extract_function_calls(node) # P25
|
|
156
|
+
|
|
157
|
+
return Symbol(
|
|
158
|
+
name=f"{class_name}.{node.name}",
|
|
159
|
+
kind=SymbolKind.METHOD,
|
|
160
|
+
line=node.lineno,
|
|
161
|
+
end_line=node.end_lineno or node.lineno,
|
|
162
|
+
signature=signature,
|
|
163
|
+
docstring=docstring,
|
|
164
|
+
contracts=contracts,
|
|
165
|
+
has_doctest=has_doctest,
|
|
166
|
+
internal_imports=internal_imports,
|
|
167
|
+
impure_calls=impure_calls,
|
|
168
|
+
code_lines=code_lines,
|
|
169
|
+
doctest_lines=doctest_lines,
|
|
170
|
+
function_calls=function_calls,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pre(lambda node: isinstance(node, ast.ClassDef) and hasattr(node, 'name') and hasattr(node, 'lineno'))
|
|
175
|
+
@post(lambda result: result.kind == SymbolKind.CLASS)
|
|
176
|
+
def _parse_class(node: ast.ClassDef) -> Symbol:
|
|
177
|
+
"""Parse a class definition into a Symbol."""
|
|
178
|
+
docstring = ast.get_docstring(node)
|
|
179
|
+
|
|
180
|
+
return Symbol(
|
|
181
|
+
name=node.name,
|
|
182
|
+
kind=SymbolKind.CLASS,
|
|
183
|
+
line=node.lineno,
|
|
184
|
+
end_line=node.end_lineno or node.lineno,
|
|
185
|
+
docstring=docstring,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pre(lambda node: isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef))
|
|
190
|
+
@post(lambda result: all(c.kind in ("pre", "post") for c in result))
|
|
191
|
+
def _extract_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[Contract]:
|
|
192
|
+
"""Extract @pre and @post contracts from function decorators."""
|
|
193
|
+
contracts: list[Contract] = []
|
|
194
|
+
|
|
195
|
+
for decorator in node.decorator_list:
|
|
196
|
+
contract = _parse_decorator_as_contract(decorator)
|
|
197
|
+
if contract:
|
|
198
|
+
contracts.append(contract)
|
|
199
|
+
|
|
200
|
+
return contracts
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@pre(lambda decorator: not isinstance(decorator, ast.Call) or hasattr(decorator, "func"))
|
|
204
|
+
@post(lambda result: result is None or result.kind in ("pre", "post"))
|
|
205
|
+
def _parse_decorator_as_contract(decorator: ast.expr) -> Contract | None:
|
|
206
|
+
"""Try to parse a decorator as a contract (@pre or @post)."""
|
|
207
|
+
# Handle @pre(...) or @post(...)
|
|
208
|
+
if isinstance(decorator, ast.Call):
|
|
209
|
+
func = decorator.func
|
|
210
|
+
if isinstance(func, ast.Name) and func.id in ("pre", "post"):
|
|
211
|
+
expr = _get_contract_expression(decorator)
|
|
212
|
+
return Contract(
|
|
213
|
+
kind="pre" if func.id == "pre" else "post",
|
|
214
|
+
expression=expr,
|
|
215
|
+
line=decorator.lineno,
|
|
216
|
+
)
|
|
217
|
+
# Handle deal.pre(...) or deal.post(...)
|
|
218
|
+
if isinstance(func, ast.Attribute) and func.attr in ("pre", "post"):
|
|
219
|
+
expr = _get_contract_expression(decorator)
|
|
220
|
+
return Contract(
|
|
221
|
+
kind="pre" if func.attr == "pre" else "post",
|
|
222
|
+
expression=expr,
|
|
223
|
+
line=decorator.lineno,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pre(lambda call: isinstance(call, ast.Call))
|
|
230
|
+
def _get_contract_expression(call: ast.Call) -> str:
|
|
231
|
+
"""Extract the expression string from a contract decorator call."""
|
|
232
|
+
if call.args:
|
|
233
|
+
return ast.unparse(call.args[0])
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pre(lambda node: (
|
|
238
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
|
|
239
|
+
hasattr(node, 'args')
|
|
240
|
+
))
|
|
241
|
+
@post(lambda result: result.startswith("(") and ")" in result)
|
|
242
|
+
def _build_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
243
|
+
"""Build a signature string from function arguments."""
|
|
244
|
+
args = node.args
|
|
245
|
+
parts: list[str] = []
|
|
246
|
+
|
|
247
|
+
# Regular args
|
|
248
|
+
for arg in args.args:
|
|
249
|
+
part = arg.arg
|
|
250
|
+
if arg.annotation:
|
|
251
|
+
part += f": {ast.unparse(arg.annotation)}"
|
|
252
|
+
parts.append(part)
|
|
253
|
+
|
|
254
|
+
sig = f"({', '.join(parts)})"
|
|
255
|
+
|
|
256
|
+
# Return type
|
|
257
|
+
if node.returns:
|
|
258
|
+
sig += f" -> {ast.unparse(node.returns)}"
|
|
259
|
+
|
|
260
|
+
return sig
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
264
|
+
@post(lambda result: all(isinstance(s, str) and s for s in result))
|
|
265
|
+
def _extract_imports(tree: ast.Module) -> list[str]:
|
|
266
|
+
"""Extract imported module names from AST (top-level only)."""
|
|
267
|
+
imports: list[str] = []
|
|
268
|
+
|
|
269
|
+
for node in tree.body:
|
|
270
|
+
if isinstance(node, ast.Import):
|
|
271
|
+
for alias in node.names:
|
|
272
|
+
imports.append(alias.name.split(".")[0])
|
|
273
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
274
|
+
imports.append(node.module.split(".")[0])
|
|
275
|
+
|
|
276
|
+
return list(set(imports)) # Deduplicate
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Property test generation from contracts.
|
|
3
|
+
|
|
4
|
+
DX-08: Automatically generates Hypothesis property tests from @pre/@post contracts.
|
|
5
|
+
Core module: pure logic, no I/O.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from deal import post, pre
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PropertyTestResult:
|
|
22
|
+
"""Result of running a property test."""
|
|
23
|
+
|
|
24
|
+
function_name: str
|
|
25
|
+
passed: bool
|
|
26
|
+
examples_run: int = 0
|
|
27
|
+
counterexample: dict[str, Any] | None = None
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GeneratedTest:
|
|
33
|
+
"""A generated property test."""
|
|
34
|
+
|
|
35
|
+
function_name: str
|
|
36
|
+
module_name: str
|
|
37
|
+
strategies: dict[str, str] # param_name -> strategy code
|
|
38
|
+
test_code: str
|
|
39
|
+
description: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PropertyTestReport:
|
|
44
|
+
"""Report from running property tests on multiple functions."""
|
|
45
|
+
|
|
46
|
+
functions_tested: int = 0
|
|
47
|
+
functions_passed: int = 0
|
|
48
|
+
functions_failed: int = 0
|
|
49
|
+
functions_skipped: int = 0
|
|
50
|
+
total_examples: int = 0
|
|
51
|
+
results: list[PropertyTestResult] = field(default_factory=list)
|
|
52
|
+
errors: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@post(lambda result: isinstance(result, bool))
|
|
55
|
+
def all_passed(self) -> bool:
|
|
56
|
+
"""Check if all tests passed.
|
|
57
|
+
|
|
58
|
+
>>> report = PropertyTestReport(functions_failed=0)
|
|
59
|
+
>>> report.all_passed()
|
|
60
|
+
True
|
|
61
|
+
>>> report2 = PropertyTestReport(functions_failed=1)
|
|
62
|
+
>>> report2.all_passed()
|
|
63
|
+
False
|
|
64
|
+
"""
|
|
65
|
+
return self.functions_failed == 0 and len(self.errors) == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pre(lambda func: callable(func))
|
|
69
|
+
@post(lambda result: result is None or isinstance(result, dict))
|
|
70
|
+
def _infer_strategy_strings(func: Callable) -> dict[str, str] | None:
|
|
71
|
+
"""Infer strategy code strings from function.
|
|
72
|
+
|
|
73
|
+
>>> def example(x: int) -> int: return x
|
|
74
|
+
>>> result = _infer_strategy_strings(example)
|
|
75
|
+
>>> result is None or isinstance(result, dict)
|
|
76
|
+
True
|
|
77
|
+
"""
|
|
78
|
+
# Lazy import to avoid circular dependency
|
|
79
|
+
from invar.core.hypothesis_strategies import infer_strategies_for_function
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
strategy_specs = infer_strategies_for_function(func)
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if not strategy_specs:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
strategies: dict[str, str] = {}
|
|
90
|
+
for param_name, spec in strategy_specs.items():
|
|
91
|
+
try:
|
|
92
|
+
strategies[param_name] = spec.to_code()
|
|
93
|
+
except (AttributeError, TypeError):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
return strategies if strategies else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pre(lambda func: callable(func))
|
|
100
|
+
@post(lambda result: result is None or isinstance(result, GeneratedTest))
|
|
101
|
+
def generate_property_test(func: Callable) -> GeneratedTest | None:
|
|
102
|
+
"""
|
|
103
|
+
Generate a Hypothesis property test from a function's contracts.
|
|
104
|
+
|
|
105
|
+
Returns None if:
|
|
106
|
+
- Function has no @pre contracts
|
|
107
|
+
- @pre is too complex to parse
|
|
108
|
+
- Types are not inferrable
|
|
109
|
+
|
|
110
|
+
DX-08: Uses strategies.py for constraint inference and
|
|
111
|
+
hypothesis_strategies.py for type-based strategy generation.
|
|
112
|
+
|
|
113
|
+
>>> def example(x: int) -> int:
|
|
114
|
+
... return x * 2
|
|
115
|
+
>>> result = generate_property_test(example)
|
|
116
|
+
>>> result is None or isinstance(result, GeneratedTest)
|
|
117
|
+
True
|
|
118
|
+
"""
|
|
119
|
+
# Get function metadata
|
|
120
|
+
try:
|
|
121
|
+
func_name = func.__name__
|
|
122
|
+
module_name = func.__module__ or "__main__"
|
|
123
|
+
except AttributeError:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# Infer strategies
|
|
127
|
+
strategies = _infer_strategy_strings(func)
|
|
128
|
+
if not strategies:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Generate test code
|
|
132
|
+
test_code = _generate_test_code(func_name, strategies)
|
|
133
|
+
|
|
134
|
+
return GeneratedTest(
|
|
135
|
+
function_name=func_name,
|
|
136
|
+
module_name=module_name,
|
|
137
|
+
strategies=strategies,
|
|
138
|
+
test_code=test_code,
|
|
139
|
+
description=f"Property test for {func_name}",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pre(lambda func_name, strategies: isinstance(func_name, str) and isinstance(strategies, dict))
|
|
144
|
+
@post(lambda result: isinstance(result, str) and "@given" in result)
|
|
145
|
+
def _generate_test_code(func_name: str, strategies: dict[str, str]) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Generate the actual test code string.
|
|
148
|
+
|
|
149
|
+
>>> code = _generate_test_code("sqrt", {"x": "st.floats(min_value=0)"})
|
|
150
|
+
>>> "@given" in code
|
|
151
|
+
True
|
|
152
|
+
>>> "def test_sqrt_property" in code
|
|
153
|
+
True
|
|
154
|
+
"""
|
|
155
|
+
# Build @given decorator arguments
|
|
156
|
+
given_args = ", ".join(f"{name}={strat}" for name, strat in strategies.items())
|
|
157
|
+
|
|
158
|
+
# Build test function
|
|
159
|
+
param_list = ", ".join(strategies.keys())
|
|
160
|
+
|
|
161
|
+
return f'''@given({given_args})
|
|
162
|
+
def test_{func_name}_property({param_list}):
|
|
163
|
+
"""Auto-generated property test for {func_name}."""
|
|
164
|
+
{func_name}({param_list}) # @post verified by deal at runtime
|
|
165
|
+
'''
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pre(lambda dec: isinstance(dec, ast.Call) and hasattr(dec, "func"))
|
|
169
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
170
|
+
def _check_decorator_contracts(dec: ast.Call) -> tuple[bool, bool]:
|
|
171
|
+
"""Check if decorator is @pre or @post, return (has_pre, has_post).
|
|
172
|
+
|
|
173
|
+
>>> import ast
|
|
174
|
+
>>> code = "pre(lambda x: x > 0)"
|
|
175
|
+
>>> tree = ast.parse(code, mode='eval')
|
|
176
|
+
>>> isinstance(tree.body, ast.Call)
|
|
177
|
+
True
|
|
178
|
+
"""
|
|
179
|
+
func = dec.func
|
|
180
|
+
has_pre, has_post = False, False
|
|
181
|
+
# @pre(...) or @post(...)
|
|
182
|
+
if isinstance(func, ast.Name):
|
|
183
|
+
has_pre = func.id == "pre"
|
|
184
|
+
has_post = func.id == "post"
|
|
185
|
+
# @deal.pre(...) or @deal.post(...)
|
|
186
|
+
elif isinstance(func, ast.Attribute):
|
|
187
|
+
has_pre = func.attr == "pre"
|
|
188
|
+
has_post = func.attr == "post"
|
|
189
|
+
return has_pre, has_post
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
193
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
194
|
+
def _get_function_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[bool, bool]:
|
|
195
|
+
"""Check function decorators for contracts, return (has_pre, has_post).
|
|
196
|
+
|
|
197
|
+
>>> import ast
|
|
198
|
+
>>> code = '''
|
|
199
|
+
... @pre(lambda x: x > 0)
|
|
200
|
+
... def foo(x): pass
|
|
201
|
+
... '''
|
|
202
|
+
>>> tree = ast.parse(code)
|
|
203
|
+
>>> func_node = tree.body[0]
|
|
204
|
+
>>> isinstance(func_node, ast.FunctionDef)
|
|
205
|
+
True
|
|
206
|
+
"""
|
|
207
|
+
has_pre, has_post = False, False
|
|
208
|
+
for dec in node.decorator_list:
|
|
209
|
+
if isinstance(dec, ast.Call):
|
|
210
|
+
dec_pre, dec_post = _check_decorator_contracts(dec)
|
|
211
|
+
has_pre = has_pre or dec_pre
|
|
212
|
+
has_post = has_post or dec_post
|
|
213
|
+
return has_pre, has_post
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pre(lambda source: isinstance(source, str) and len(source) > 0)
|
|
217
|
+
@post(lambda result: isinstance(result, list))
|
|
218
|
+
def find_contracted_functions(source: str) -> list[dict[str, Any]]:
|
|
219
|
+
"""
|
|
220
|
+
Find all functions with @pre/@post contracts in source code.
|
|
221
|
+
|
|
222
|
+
>>> source = '''
|
|
223
|
+
... from deal import pre, post
|
|
224
|
+
... @pre(lambda x: x > 0)
|
|
225
|
+
... def sqrt(x: float) -> float:
|
|
226
|
+
... return x ** 0.5
|
|
227
|
+
... '''
|
|
228
|
+
>>> funcs = find_contracted_functions(source)
|
|
229
|
+
>>> len(funcs) >= 0
|
|
230
|
+
True
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
tree = ast.parse(source)
|
|
234
|
+
except (SyntaxError, ValueError, TypeError):
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
functions: list[dict[str, Any]] = []
|
|
238
|
+
for node in ast.walk(tree):
|
|
239
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
240
|
+
has_pre, has_post = _get_function_contracts(node)
|
|
241
|
+
if has_pre or has_post:
|
|
242
|
+
functions.append({
|
|
243
|
+
"name": node.name,
|
|
244
|
+
"lineno": node.lineno,
|
|
245
|
+
"has_pre": has_pre,
|
|
246
|
+
"has_post": has_post,
|
|
247
|
+
"params": _extract_params(node),
|
|
248
|
+
"return_type": _extract_return_type(node),
|
|
249
|
+
})
|
|
250
|
+
return functions
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and hasattr(node, "args"))
|
|
254
|
+
@post(lambda result: isinstance(result, list))
|
|
255
|
+
def _extract_params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict[str, str]]:
|
|
256
|
+
"""Extract parameter names and type annotations from function node."""
|
|
257
|
+
params = []
|
|
258
|
+
for arg in node.args.args:
|
|
259
|
+
param_info = {"name": arg.arg, "type": None}
|
|
260
|
+
if arg.annotation:
|
|
261
|
+
param_info["type"] = ast.unparse(arg.annotation)
|
|
262
|
+
params.append(param_info)
|
|
263
|
+
return params
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
267
|
+
@post(lambda result: result is None or isinstance(result, str))
|
|
268
|
+
def _extract_return_type(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
269
|
+
"""Extract return type annotation from function node."""
|
|
270
|
+
if node.returns:
|
|
271
|
+
return ast.unparse(node.returns)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pre(lambda func, strategies, max_examples=100: callable(func) and isinstance(strategies, dict))
|
|
276
|
+
@post(lambda result: result is None or callable(result))
|
|
277
|
+
def build_test_function(
|
|
278
|
+
func: Callable,
|
|
279
|
+
strategies: dict[str, str],
|
|
280
|
+
max_examples: int = 100,
|
|
281
|
+
) -> Callable | None:
|
|
282
|
+
"""
|
|
283
|
+
Build an executable test function using Hypothesis.
|
|
284
|
+
|
|
285
|
+
Returns None if hypothesis is not available.
|
|
286
|
+
|
|
287
|
+
>>> def example(x: int) -> int:
|
|
288
|
+
... return x * 2
|
|
289
|
+
>>> test_fn = build_test_function(example, {"x": "st.integers()"})
|
|
290
|
+
>>> test_fn is None or callable(test_fn)
|
|
291
|
+
True
|
|
292
|
+
"""
|
|
293
|
+
# Lazy import: hypothesis is optional dependency
|
|
294
|
+
try:
|
|
295
|
+
from hypothesis import given, settings
|
|
296
|
+
from hypothesis import strategies as st
|
|
297
|
+
except ImportError:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
# Build strategy dict
|
|
301
|
+
strategy_dict = {}
|
|
302
|
+
for param_name, strat_code in strategies.items():
|
|
303
|
+
# Evaluate the strategy code
|
|
304
|
+
try:
|
|
305
|
+
# strat_code is like "st.integers(min_value=0)"
|
|
306
|
+
strategy = eval(strat_code, {"st": st})
|
|
307
|
+
strategy_dict[param_name] = strategy
|
|
308
|
+
except Exception:
|
|
309
|
+
# If evaluation fails, use a fallback
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
if not strategy_dict:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# Create the test function
|
|
316
|
+
@settings(max_examples=max_examples)
|
|
317
|
+
@given(**strategy_dict)
|
|
318
|
+
def property_test(**kwargs):
|
|
319
|
+
func(**kwargs)
|
|
320
|
+
|
|
321
|
+
property_test.__name__ = f"test_{func.__name__}_property"
|
|
322
|
+
return property_test
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@pre(lambda func, max_examples: callable(func) and max_examples > 0)
|
|
326
|
+
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
327
|
+
def run_property_test(
|
|
328
|
+
func: Callable,
|
|
329
|
+
max_examples: int = 100,
|
|
330
|
+
) -> PropertyTestResult:
|
|
331
|
+
"""
|
|
332
|
+
Run a property test on a single function.
|
|
333
|
+
|
|
334
|
+
Generates strategies from contracts and runs Hypothesis.
|
|
335
|
+
|
|
336
|
+
>>> from deal import pre, post
|
|
337
|
+
>>> @pre(lambda x: x >= 0)
|
|
338
|
+
... @post(lambda result: result >= 0)
|
|
339
|
+
... def square(x: int) -> int:
|
|
340
|
+
... return x * x
|
|
341
|
+
>>> result = run_property_test(square, max_examples=10)
|
|
342
|
+
>>> isinstance(result, PropertyTestResult)
|
|
343
|
+
True
|
|
344
|
+
"""
|
|
345
|
+
func_name = getattr(func, "__name__", "unknown")
|
|
346
|
+
|
|
347
|
+
# Generate test
|
|
348
|
+
generated = generate_property_test(func)
|
|
349
|
+
if generated is None:
|
|
350
|
+
return PropertyTestResult(
|
|
351
|
+
function_name=func_name,
|
|
352
|
+
passed=True, # No test generated = skip, not fail
|
|
353
|
+
examples_run=0,
|
|
354
|
+
error="Could not generate test (no contracts or unparseable)",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Build executable test
|
|
358
|
+
test_fn = build_test_function(func, generated.strategies, max_examples)
|
|
359
|
+
if test_fn is None:
|
|
360
|
+
return PropertyTestResult(
|
|
361
|
+
function_name=func_name,
|
|
362
|
+
passed=True,
|
|
363
|
+
examples_run=0,
|
|
364
|
+
error="Could not build test (hypothesis not available or strategy error)",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Run the test
|
|
368
|
+
try:
|
|
369
|
+
test_fn()
|
|
370
|
+
return PropertyTestResult(
|
|
371
|
+
function_name=func_name,
|
|
372
|
+
passed=True,
|
|
373
|
+
examples_run=max_examples,
|
|
374
|
+
)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
# Extract counterexample if available
|
|
377
|
+
error_str = str(e)
|
|
378
|
+
return PropertyTestResult(
|
|
379
|
+
function_name=func_name,
|
|
380
|
+
passed=False,
|
|
381
|
+
examples_run=max_examples,
|
|
382
|
+
error=error_str,
|
|
383
|
+
)
|