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.
Files changed (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. 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
+ )