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
@@ -0,0 +1,324 @@
1
+ """
2
+ Fix suggestion generation for Guard (Phase 7.3, 11 P27).
3
+
4
+ Generates concrete, usable fix code for violations.
5
+ Agents need exact code, not vague descriptions.
6
+
7
+ P27: Enhanced Context for Agent Decision
8
+ - Show multiple pattern options for constraints
9
+ - Guard provides options, Agent decides
10
+
11
+ No I/O operations - receives parsed data only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from deal import post, pre
19
+
20
+ from invar.core.models import Symbol, SymbolKind
21
+
22
+ # P27: Common constraint patterns by type (ordered by commonality)
23
+ CONSTRAINT_PATTERNS: dict[str, list[str]] = {
24
+ "int": ["{name} >= 0", "{name} > 0", "{name} != 0"],
25
+ "float": ["{name} >= 0", "{name} > 0", "{name} != 0"],
26
+ "str": ["len({name}) > 0", "{name}", "{name}.strip()"],
27
+ "list": ["len({name}) > 0", "{name}"],
28
+ "dict": ["len({name}) > 0", "{name}"],
29
+ "set": ["len({name}) > 0", "{name}"],
30
+ "tuple": ["len({name}) > 0", "{name}"],
31
+ "bytes": ["len({name}) > 0", "{name}"],
32
+ "Optional": ["{name} is not None", "{name}"],
33
+ }
34
+
35
+
36
+ @pre(lambda signature: signature.startswith("(") or signature == "")
37
+ def generate_contract_suggestion(signature: str) -> str:
38
+ """
39
+ Generate a suggested @pre contract based on function signature.
40
+
41
+ Uses common patterns:
42
+ - int/float: param >= 0
43
+ - str/list/dict/set/tuple: len(param) > 0
44
+ - Optional/None union: param is not None
45
+
46
+ Examples:
47
+ >>> generate_contract_suggestion("(x: int, y: int) -> int")
48
+ '@pre(lambda x, y: x >= 0 and y >= 0)'
49
+ >>> generate_contract_suggestion("(name: str) -> bool")
50
+ '@pre(lambda name: len(name) > 0)'
51
+ >>> generate_contract_suggestion("(items: list[int]) -> int")
52
+ '@pre(lambda items: len(items) > 0)'
53
+ >>> generate_contract_suggestion("(x, y)")
54
+ ''
55
+ >>> generate_contract_suggestion("(value: Optional[str]) -> str")
56
+ '@pre(lambda value: value is not None)'
57
+ """
58
+ params = _extract_params(signature)
59
+ if not params:
60
+ return ""
61
+
62
+ constraints = []
63
+ param_names = []
64
+
65
+ for name, type_hint in params:
66
+ if not name: # Skip empty names from malformed signatures
67
+ continue
68
+ param_names.append(name)
69
+ if not type_hint:
70
+ continue
71
+
72
+ constraint = _suggest_constraint(name, type_hint)
73
+ if constraint:
74
+ constraints.append(constraint)
75
+
76
+ if not constraints:
77
+ return ""
78
+
79
+ params_str = ", ".join(param_names)
80
+ constraints_str = " and ".join(constraints)
81
+ return f"@pre(lambda {params_str}: {constraints_str})"
82
+
83
+
84
+ @pre(lambda signature: signature.startswith("(") or signature == "")
85
+ def _extract_params(signature: str) -> list[tuple[str, str | None]]:
86
+ """
87
+ Extract parameters and their types from a signature.
88
+
89
+ Examples:
90
+ >>> _extract_params("(x: int, y: str) -> bool")
91
+ [('x', 'int'), ('y', 'str')]
92
+ >>> _extract_params("(x, y)")
93
+ [('x', None), ('y', None)]
94
+ >>> _extract_params("(items: list[int], n: int = 10) -> list")
95
+ [('items', 'list[int]'), ('n', 'int')]
96
+ """
97
+ if not signature:
98
+ return []
99
+
100
+ match = re.match(r"\(([^)]*)\)", signature)
101
+ if not match:
102
+ return []
103
+
104
+ params = []
105
+ for param in match.group(1).split(","):
106
+ param = param.strip()
107
+ if not param:
108
+ continue
109
+
110
+ if ": " in param:
111
+ name, type_hint = param.split(": ", 1)
112
+ # Handle default values
113
+ if "=" in type_hint:
114
+ type_hint = type_hint.split("=")[0].strip()
115
+ params.append((name.strip(), type_hint.strip()))
116
+ else:
117
+ # No type annotation
118
+ if "=" in param:
119
+ param = param.split("=")[0].strip()
120
+ params.append((param, None))
121
+
122
+ return params
123
+
124
+
125
+ @pre(lambda name, type_hint: len(name) > 0 and len(type_hint) > 0)
126
+ def _suggest_constraint(name: str, type_hint: str) -> str | None:
127
+ """
128
+ Suggest a constraint for a parameter based on its type.
129
+
130
+ Examples:
131
+ >>> _suggest_constraint("x", "int")
132
+ 'x >= 0'
133
+ >>> _suggest_constraint("name", "str")
134
+ 'len(name) > 0'
135
+ >>> _suggest_constraint("items", "list[str]")
136
+ 'len(items) > 0'
137
+ >>> _suggest_constraint("value", "Optional[int]")
138
+ 'value is not None'
139
+ >>> _suggest_constraint("x", "SomeCustomType")
140
+ """
141
+ # Numeric types: suggest non-negative
142
+ if type_hint in ("int", "float"):
143
+ return f"{name} >= 0"
144
+
145
+ # Collection types: suggest non-empty
146
+ if type_hint in ("str", "list", "dict", "set", "tuple", "bytes"):
147
+ return f"len({name}) > 0"
148
+
149
+ # Generic collections: list[X], dict[K, V], etc.
150
+ base_match = re.match(r"^(list|dict|set|tuple)\[", type_hint)
151
+ if base_match:
152
+ return f"len({name}) > 0"
153
+
154
+ # Optional types: suggest not None
155
+ if type_hint.startswith("Optional[") or " | None" in type_hint or "None |" in type_hint:
156
+ return f"{name} is not None"
157
+
158
+ return None
159
+
160
+
161
+ @pre(lambda name, type_hint: len(name) > 0 and len(type_hint) > 0)
162
+ def _get_pattern_alternatives(name: str, type_hint: str) -> list[str]:
163
+ """
164
+ Get multiple constraint pattern alternatives for a parameter (P27).
165
+
166
+ Returns up to 3 common patterns for the type.
167
+
168
+ Examples:
169
+ >>> _get_pattern_alternatives("x", "int")
170
+ ['x >= 0', 'x > 0', 'x != 0']
171
+ >>> _get_pattern_alternatives("name", "str")
172
+ ['len(name) > 0', 'name', 'name.strip()']
173
+ >>> _get_pattern_alternatives("value", "Optional[int]")
174
+ ['value is not None', 'value']
175
+ >>> _get_pattern_alternatives("x", "CustomType")
176
+ []
177
+ """
178
+ # Check exact type matches
179
+ if type_hint in CONSTRAINT_PATTERNS:
180
+ return [p.format(name=name) for p in CONSTRAINT_PATTERNS[type_hint]]
181
+
182
+ # Generic collections: list[X], dict[K, V], etc.
183
+ base_match = re.match(r"^(list|dict|set|tuple)\[", type_hint)
184
+ if base_match:
185
+ base_type = base_match.group(1)
186
+ if base_type in CONSTRAINT_PATTERNS:
187
+ return [p.format(name=name) for p in CONSTRAINT_PATTERNS[base_type]]
188
+
189
+ # Optional types
190
+ if type_hint.startswith("Optional[") or " | None" in type_hint or "None |" in type_hint:
191
+ return [p.format(name=name) for p in CONSTRAINT_PATTERNS["Optional"]]
192
+
193
+ return []
194
+
195
+
196
+ @pre(lambda signature: signature.startswith("(") or signature == "")
197
+ def generate_pattern_options(signature: str) -> str:
198
+ """
199
+ Generate multiple constraint pattern options for each parameter (P27).
200
+
201
+ Returns a formatted string showing alternatives for each typed parameter.
202
+
203
+ Examples:
204
+ >>> generate_pattern_options("(x: int, y: str) -> int")
205
+ 'Patterns: x >= 0 | x > 0 | x != 0, len(y) > 0 | y | y.strip()'
206
+ >>> generate_pattern_options("(data, config)")
207
+ ''
208
+ """
209
+ params = _extract_params(signature)
210
+ if not params:
211
+ return ""
212
+
213
+ all_patterns: list[str] = []
214
+ for name, type_hint in params:
215
+ if not name or not type_hint: # Skip empty names from malformed signatures
216
+ continue
217
+ patterns = _get_pattern_alternatives(name, type_hint)
218
+ if patterns:
219
+ all_patterns.append(" | ".join(patterns))
220
+
221
+ if not all_patterns:
222
+ return ""
223
+
224
+ return f"Patterns: {', '.join(all_patterns)}"
225
+
226
+
227
+ @pre(lambda signature: signature.startswith("(") or signature == "")
228
+ @post(lambda result: isinstance(result, str))
229
+ def _generate_lambda_skeleton(signature: str) -> str:
230
+ """
231
+ Generate a lambda skeleton from function signature (P4).
232
+
233
+ Returns skeleton with parameters extracted, condition placeholder.
234
+
235
+ Examples:
236
+ >>> _generate_lambda_skeleton("(x: int, y: int) -> int")
237
+ '@pre(lambda x, y: <condition>) or @post(lambda result: <condition>)'
238
+ >>> _generate_lambda_skeleton("(items: list) -> None")
239
+ '@pre(lambda items: <condition>) or @post(lambda result: <condition>)'
240
+ >>> _generate_lambda_skeleton("() -> int")
241
+ '@post(lambda result: <condition>)'
242
+ """
243
+ params = _extract_params(signature)
244
+ param_names = [name for name, _ in params]
245
+
246
+ if not param_names:
247
+ return "@post(lambda result: <condition>)"
248
+
249
+ params_str = ", ".join(param_names)
250
+ return f"@pre(lambda {params_str}: <condition>) or @post(lambda result: <condition>)"
251
+
252
+
253
+ # Prefixes for violation type suggestions
254
+ _VIOLATION_PREFIXES = {
255
+ "missing_contract": ("Add: ", "Add: "),
256
+ "empty_contract": ("Replace with: ", "Replace with: "),
257
+ "redundant_type_contract": ("Replace with business logic: ", "Replace with: "),
258
+ "semantic_tautology": ("Replace tautology with meaningful constraint: ", "Replace tautology with: "),
259
+ }
260
+
261
+
262
+ @pre(lambda prefix, suggestion, patterns: bool(prefix) and bool(suggestion))
263
+ @post(lambda result: isinstance(result, str) and len(result) > 0)
264
+ def _format_with_patterns(prefix: str, suggestion: str, patterns: str) -> str:
265
+ """Format suggestion with optional patterns.
266
+
267
+ >>> _format_with_patterns("Add: ", "check(x)", "Patterns: x > 0")
268
+ 'Add: check(x)\\nPatterns: x > 0'
269
+ """
270
+ result = f"{prefix}{suggestion}"
271
+ if patterns:
272
+ result += f"\n{patterns}"
273
+ return result
274
+
275
+
276
+ @pre(
277
+ lambda symbol, violation_type: violation_type
278
+ in ("missing_contract", "empty_contract", "redundant_type_contract", "semantic_tautology", "")
279
+ )
280
+ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
281
+ """
282
+ Format a complete suggestion message for a violation.
283
+
284
+ Phase 9.2 P4: Generate lambda skeletons when no type-based suggestion available.
285
+ P7: Added semantic_tautology support.
286
+ P27: Show pattern alternatives (Guard provides options, Agent decides).
287
+
288
+ Examples:
289
+ >>> from invar.core.models import Symbol, SymbolKind
290
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5,
291
+ ... signature="(x: int, y: int) -> int")
292
+ >>> msg = format_suggestion_for_violation(sym, "missing_contract")
293
+ >>> "@pre(lambda x, y: x >= 0 and y >= 0)" in msg
294
+ True
295
+ >>> "Patterns:" in msg # P27: shows alternatives
296
+ True
297
+ >>> # P4: skeleton when no type-based suggestion
298
+ >>> sym2 = Symbol(name="process", kind=SymbolKind.FUNCTION, line=1, end_line=5,
299
+ ... signature="(data, config)")
300
+ >>> msg2 = format_suggestion_for_violation(sym2, "missing_contract")
301
+ >>> "@pre(lambda data, config: <condition>)" in msg2
302
+ True
303
+ """
304
+ if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
305
+ return ""
306
+
307
+ if violation_type not in _VIOLATION_PREFIXES:
308
+ return ""
309
+
310
+ # Guard against malformed signatures
311
+ sig = symbol.signature
312
+ if not (sig.startswith("(") or sig == ""):
313
+ return ""
314
+
315
+ suggestion_prefix, skeleton_prefix = _VIOLATION_PREFIXES[violation_type]
316
+ patterns = generate_pattern_options(sig)
317
+ suggestion = generate_contract_suggestion(sig)
318
+
319
+ if suggestion:
320
+ return _format_with_patterns(suggestion_prefix, suggestion, patterns)
321
+
322
+ # P4: Generate lambda skeleton when no type-based suggestion
323
+ skeleton = _generate_lambda_skeleton(sig)
324
+ return f"{skeleton_prefix}{skeleton}"
@@ -0,0 +1,137 @@
1
+ """Semantic tautology detection for contract quality (P7). No I/O operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from deal import post, pre
8
+
9
+ from invar.core.lambda_helpers import find_lambda
10
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
11
+ from invar.core.suggestions import format_suggestion_for_violation
12
+
13
+
14
+ @pre(lambda expression: ("lambda" in expression and ":" in expression) or not expression.strip())
15
+ def is_semantic_tautology(expression: str) -> tuple[bool, str]:
16
+ """Check if a contract expression is a semantic tautology.
17
+
18
+ Returns (is_tautology, pattern_description).
19
+
20
+ P7: Detects patterns that are always true:
21
+ - x == x (identity comparison)
22
+ - len(x) >= 0 (length always non-negative)
23
+ - isinstance(x, object) (everything is object)
24
+ - x or True (always true due to True)
25
+ - True and x (simplifies but starts with True)
26
+
27
+ Examples:
28
+ >>> is_semantic_tautology("lambda x: x == x")
29
+ (True, 'x == x is always True')
30
+ >>> is_semantic_tautology("lambda x: len(x) >= 0")
31
+ (True, 'len(x) >= 0 is always True for any sequence')
32
+ >>> is_semantic_tautology("lambda x: isinstance(x, object)")
33
+ (True, 'isinstance(x, object) is always True')
34
+ >>> is_semantic_tautology("lambda x: x > 0")
35
+ (False, '')
36
+ >>> is_semantic_tautology("lambda x: x or True")
37
+ (True, 'expression contains unconditional True')
38
+ """
39
+ if not expression.strip():
40
+ return (False, "")
41
+ try:
42
+ tree = ast.parse(expression, mode="eval")
43
+ lambda_node = find_lambda(tree)
44
+ if lambda_node is None:
45
+ return (False, "")
46
+ return _check_tautology_patterns(lambda_node.body)
47
+ except (SyntaxError, TypeError, ValueError):
48
+ return (False, "")
49
+
50
+
51
+ @post(lambda result: isinstance(result, tuple) and len(result) == 2)
52
+ def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
53
+ """Check for common tautology patterns in AST node."""
54
+ # Identity comparison pattern (e.g., x == x)
55
+ if (
56
+ isinstance(node, ast.Compare)
57
+ and len(node.ops) == 1
58
+ and isinstance(node.ops[0], (ast.Eq, ast.Is))
59
+ ):
60
+ left = ast.unparse(node.left)
61
+ right = ast.unparse(node.comparators[0])
62
+ if left == right:
63
+ return (True, f"{left} == {right} is always True")
64
+
65
+ # Length non-negative pattern (e.g., len(x) >= 0)
66
+ if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
67
+ left = node.left
68
+ op = node.ops[0]
69
+ right = node.comparators[0]
70
+ if (
71
+ isinstance(left, ast.Call)
72
+ and isinstance(left.func, ast.Name)
73
+ and left.func.id == "len"
74
+ and isinstance(op, ast.GtE)
75
+ and isinstance(right, ast.Constant)
76
+ and right.value == 0
77
+ ):
78
+ arg = ast.unparse(left.args[0]) if left.args else "x"
79
+ return (True, f"len({arg}) >= 0 is always True for any sequence")
80
+
81
+ # isinstance with object pattern (always True)
82
+ if (
83
+ isinstance(node, ast.Call)
84
+ and isinstance(node.func, ast.Name)
85
+ and node.func.id == "isinstance"
86
+ and len(node.args) == 2
87
+ ):
88
+ type_arg = node.args[1]
89
+ if isinstance(type_arg, ast.Name) and type_arg.id == "object":
90
+ arg = ast.unparse(node.args[0])
91
+ return (True, f"isinstance({arg}, object) is always True")
92
+
93
+ # Pattern: x or True, True or x (always true)
94
+ if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
95
+ for val in node.values:
96
+ if isinstance(val, ast.Constant) and val.value is True:
97
+ return (True, "expression contains unconditional True")
98
+
99
+ return (False, "")
100
+
101
+
102
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
103
+ def check_semantic_tautology(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
104
+ """Check for semantic tautology contracts. Core files only.
105
+
106
+ P7: Detects contracts that are always true due to semantic patterns:
107
+ - x == x, len(x) >= 0, isinstance(x, object), x or True
108
+
109
+ Examples:
110
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
111
+ >>> c = Contract(kind="pre", expression="lambda x: x == x", line=1)
112
+ >>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
113
+ >>> vs = check_semantic_tautology(FileInfo(path="c.py", lines=10, symbols=[s], is_core=True), RuleConfig())
114
+ >>> vs[0].rule
115
+ 'semantic_tautology'
116
+ """
117
+ violations: list[Violation] = []
118
+ if not file_info.is_core:
119
+ return violations
120
+ for symbol in file_info.symbols:
121
+ if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
122
+ continue
123
+ for contract in symbol.contracts:
124
+ is_tautology, pattern_desc = is_semantic_tautology(contract.expression)
125
+ if is_tautology:
126
+ kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
127
+ violations.append(
128
+ Violation(
129
+ rule="semantic_tautology",
130
+ severity=Severity.WARNING,
131
+ file=file_info.path,
132
+ line=contract.line,
133
+ message=f"{kind} '{symbol.name}' has tautological contract: {pattern_desc}",
134
+ suggestion=format_suggestion_for_violation(symbol, "semantic_tautology"),
135
+ )
136
+ )
137
+ return violations
@@ -0,0 +1,114 @@
1
+ """
2
+ Timeout inference for CrossHair based on code characteristics.
3
+
4
+ Part of DX-12: Hypothesis as CrossHair fallback.
5
+ Extracted from hypothesis_strategies.py to reduce file size.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ from deal import post, pre
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+
21
+ @dataclass
22
+ class TimeoutTier:
23
+ """Timeout tier for CrossHair based on code characteristics."""
24
+
25
+ name: str
26
+ timeout: int
27
+ description: str
28
+
29
+
30
+ TIMEOUT_TIERS = {
31
+ "pure_python": TimeoutTier("pure_python", 10, "Pure Python, no external libs"),
32
+ "stdlib_only": TimeoutTier("stdlib_only", 15, "Uses collections, itertools"),
33
+ "numpy_pandas": TimeoutTier("numpy_pandas", 5, "Quick check, likely to skip"),
34
+ "complex_nested": TimeoutTier("complex_nested", 30, "Deep recursion, many branches"),
35
+ }
36
+
37
+ # Libraries that CrossHair cannot handle well
38
+ LIBRARY_BLACKLIST = frozenset([
39
+ "numpy", "pandas", "torch", "tensorflow", "scipy",
40
+ "sklearn", "cv2", "PIL", "requests", "aiohttp",
41
+ ])
42
+
43
+
44
+ @pre(lambda func: callable(func))
45
+ @post(lambda result: isinstance(result, int) and result > 0)
46
+ def infer_timeout(func: Callable) -> int:
47
+ """
48
+ Infer appropriate CrossHair timeout from function source.
49
+
50
+ Args:
51
+ func: The function to analyze
52
+
53
+ Returns:
54
+ Timeout in seconds
55
+
56
+ >>> def pure_func(x: int) -> int: return x * 2
57
+ >>> infer_timeout(pure_func)
58
+ 10
59
+ """
60
+ try:
61
+ source = inspect.getsource(func)
62
+ except (OSError, TypeError):
63
+ return TIMEOUT_TIERS["pure_python"].timeout
64
+
65
+ # Check for blacklisted libraries
66
+ for lib in LIBRARY_BLACKLIST:
67
+ if re.search(rf"\b{lib}\b", source):
68
+ return TIMEOUT_TIERS["numpy_pandas"].timeout
69
+
70
+ # Count complexity indicators
71
+ nesting_depth = _estimate_nesting_depth(source)
72
+ branch_count = _count_branches(source)
73
+
74
+ if nesting_depth > 4 or branch_count > 10:
75
+ return TIMEOUT_TIERS["complex_nested"].timeout
76
+
77
+ if _uses_only_stdlib(source):
78
+ return TIMEOUT_TIERS["stdlib_only"].timeout
79
+
80
+ return TIMEOUT_TIERS["pure_python"].timeout
81
+
82
+
83
+ @pre(lambda source: isinstance(source, str))
84
+ @post(lambda result: isinstance(result, int) and result >= 0)
85
+ def _estimate_nesting_depth(source: str) -> int:
86
+ """Estimate maximum nesting depth from indentation."""
87
+ max_indent = 0
88
+ for line in source.split("\n"):
89
+ stripped = line.lstrip()
90
+ if stripped and not stripped.startswith("#"):
91
+ indent = len(line) - len(stripped)
92
+ spaces = indent // 4 # Assuming 4-space indent
93
+ max_indent = max(max_indent, spaces)
94
+ return max_indent
95
+
96
+
97
+ @pre(lambda source: isinstance(source, str))
98
+ @post(lambda result: isinstance(result, int) and result >= 0)
99
+ def _count_branches(source: str) -> int:
100
+ """Count branching statements (if, for, while, try)."""
101
+ return len(re.findall(r"\b(if|for|while|try|elif|except)\b", source))
102
+
103
+
104
+ @pre(lambda source: isinstance(source, str))
105
+ @post(lambda result: isinstance(result, bool))
106
+ def _uses_only_stdlib(source: str) -> bool:
107
+ """Check if source only uses standard library."""
108
+ stdlib_patterns = ["collections", "itertools", "functools", "typing", "dataclasses"]
109
+ third_party_patterns = ["pandas", "numpy", "requests", "flask", "django"]
110
+
111
+ has_stdlib = any(pat in source for pat in stdlib_patterns)
112
+ has_third_party = any(pat in source for pat in third_party_patterns)
113
+
114
+ return has_stdlib and not has_third_party