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/rules.py ADDED
@@ -0,0 +1,435 @@
1
+ """Rule engine for Guard. Rules check FileInfo and produce Violations. No I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from deal import post, pre
8
+
9
+ from invar.core.contracts import (
10
+ check_empty_contracts,
11
+ check_param_mismatch,
12
+ check_partial_contract,
13
+ check_redundant_type_contracts,
14
+ check_semantic_tautology,
15
+ )
16
+ from invar.core.extraction import format_extraction_hint
17
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
18
+ from invar.core.must_use import check_must_use
19
+ from invar.core.purity import check_impure_calls, check_internal_imports
20
+ from invar.core.suggestions import format_suggestion_for_violation
21
+ from invar.core.utils import get_excluded_rules
22
+
23
+ # P17: Pure alternatives for forbidden imports (module → suggestion)
24
+ FORBIDDEN_IMPORT_ALTERNATIVES: dict[str, str] = {
25
+ "os": "Inject paths as strings",
26
+ "sys": "Pass sys.argv as parameter",
27
+ "pathlib": "Use string operations",
28
+ "subprocess": "Move to Shell",
29
+ "shutil": "Move to Shell",
30
+ "io": "Pass content as str/bytes",
31
+ "socket": "Move to Shell",
32
+ "requests": "Move HTTP to Shell",
33
+ "urllib": "Move to Shell",
34
+ "datetime": "Inject now as parameter",
35
+ "random": "Inject random values",
36
+ "open": "Shell reads, Core processes",
37
+ }
38
+
39
+ # Type alias for rule functions
40
+ RuleFunc = Callable[[FileInfo, RuleConfig], list[Violation]]
41
+
42
+
43
+ @post(lambda result: isinstance(result, str))
44
+ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> str:
45
+ """Build suggestion message with extraction hints."""
46
+ if extraction_hint:
47
+ return f"{base}\nExtractable groups:\n{extraction_hint}"
48
+ return f"{base}{func_hint}" if func_hint else base
49
+
50
+
51
+ @pre(lambda file_info: isinstance(file_info, FileInfo))
52
+ @post(lambda result: isinstance(result, str))
53
+ def _get_func_hint(file_info: FileInfo) -> str:
54
+ """Get top 5 largest functions as hint string."""
55
+ funcs = sorted(
56
+ [(s.name, s.end_line - s.line + 1) for s in file_info.symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)],
57
+ key=lambda x: -x[1],
58
+ )[:5]
59
+ return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
60
+
61
+
62
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
63
+ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
64
+ """
65
+ Check if file exceeds maximum line count or warning threshold.
66
+
67
+ P18: Shows function groups in size warnings to help agents decide what to extract.
68
+ P25: Shows extractable groups with dependencies for warnings.
69
+
70
+ Examples:
71
+ >>> from invar.core.models import FileInfo, RuleConfig
72
+ >>> check_file_size(FileInfo(path="ok.py", lines=100), RuleConfig())
73
+ []
74
+ >>> len(check_file_size(FileInfo(path="big.py", lines=600), RuleConfig()))
75
+ 1
76
+ >>> # P8: Warning at 80% threshold (400 lines when max is 500)
77
+ >>> vs = check_file_size(FileInfo(path="growing.py", lines=420), RuleConfig())
78
+ >>> len(vs) == 1 and vs[0].rule == "file_size_warning"
79
+ True
80
+ """
81
+ violations: list[Violation] = []
82
+ func_hint = _get_func_hint(file_info)
83
+ extraction_hint = format_extraction_hint(file_info)
84
+
85
+ if file_info.lines > config.max_file_lines:
86
+ violations.append(Violation(
87
+ rule="file_size", severity=Severity.ERROR, file=file_info.path, line=None,
88
+ message=f"File has {file_info.lines} lines (max: {config.max_file_lines})",
89
+ suggestion=_build_size_suggestion("Split into smaller modules.", extraction_hint, func_hint),
90
+ ))
91
+ elif config.size_warning_threshold > 0:
92
+ threshold = int(config.max_file_lines * config.size_warning_threshold)
93
+ if file_info.lines >= threshold:
94
+ pct = int(file_info.lines / config.max_file_lines * 100)
95
+ violations.append(Violation(
96
+ rule="file_size_warning", severity=Severity.WARNING, file=file_info.path, line=None,
97
+ message=f"File has {file_info.lines} lines ({pct}% of {config.max_file_lines} limit)",
98
+ suggestion=_build_size_suggestion("Consider splitting before reaching limit.", extraction_hint, func_hint),
99
+ ))
100
+ return violations
101
+
102
+
103
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
104
+ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
105
+ """
106
+ Check if any function exceeds maximum line count.
107
+
108
+ When use_code_lines is True, uses code_lines (excluding docstring).
109
+ When exclude_doctest_lines is True, subtracts doctest lines from count.
110
+
111
+ Examples:
112
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind
113
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
114
+ >>> info = FileInfo(path="test.py", lines=20, symbols=[sym])
115
+ >>> cfg = RuleConfig(max_function_lines=50)
116
+ >>> check_function_size(info, cfg)
117
+ []
118
+ """
119
+ violations: list[Violation] = []
120
+
121
+ for symbol in file_info.symbols:
122
+ if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
123
+ total_lines = symbol.end_line - symbol.line + 1
124
+ # Calculate effective line count based on config
125
+ if config.use_code_lines and symbol.code_lines is not None:
126
+ func_lines = symbol.code_lines
127
+ line_type = "code lines"
128
+ else:
129
+ func_lines = total_lines
130
+ line_type = "lines"
131
+ # Optionally exclude doctest lines
132
+ if config.exclude_doctest_lines and symbol.doctest_lines > 0:
133
+ func_lines -= symbol.doctest_lines
134
+ line_type = f"{line_type} (excl. doctest)"
135
+
136
+ if func_lines > config.max_function_lines:
137
+ # P19: Show breakdown if doctest lines exist
138
+ if symbol.doctest_lines > 0 and not config.exclude_doctest_lines:
139
+ code_only = total_lines - symbol.doctest_lines
140
+ breakdown = f" ({code_only} code + {symbol.doctest_lines} doctest)"
141
+ suggestion = f"Extract helper or set exclude_doctest_lines=true{breakdown}"
142
+ else:
143
+ suggestion = "Extract helper functions"
144
+ violations.append(
145
+ Violation(
146
+ rule="function_size",
147
+ severity=Severity.WARNING,
148
+ file=file_info.path,
149
+ line=symbol.line,
150
+ message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
151
+ suggestion=suggestion,
152
+ )
153
+ )
154
+
155
+ return violations
156
+
157
+
158
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
159
+ def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
160
+ """
161
+ Check for forbidden imports in Core files.
162
+
163
+ Only applies to files marked as Core.
164
+
165
+ Examples:
166
+ >>> from invar.core.models import FileInfo
167
+ >>> info = FileInfo(path="core/calc.py", lines=10, imports=["math"], is_core=True)
168
+ >>> cfg = RuleConfig()
169
+ >>> check_forbidden_imports(info, cfg)
170
+ []
171
+ >>> info = FileInfo(path="core/bad.py", lines=10, imports=["os"], is_core=True)
172
+ >>> violations = check_forbidden_imports(info, cfg)
173
+ >>> len(violations)
174
+ 1
175
+ """
176
+ violations: list[Violation] = []
177
+
178
+ if not file_info.is_core:
179
+ return violations
180
+
181
+ for imp in file_info.imports:
182
+ if imp in config.forbidden_imports:
183
+ # P17: Include pure alternative in suggestion
184
+ alt = FORBIDDEN_IMPORT_ALTERNATIVES.get(imp, "")
185
+ suggestion = f"Move I/O code using '{imp}' to Shell"
186
+ if alt:
187
+ suggestion += f". Alternative: {alt}"
188
+ violations.append(
189
+ Violation(
190
+ rule="forbidden_import",
191
+ severity=Severity.ERROR,
192
+ file=file_info.path,
193
+ line=None,
194
+ message=f"Imports '{imp}' (forbidden in Core)",
195
+ suggestion=suggestion,
196
+ )
197
+ )
198
+
199
+ return violations
200
+
201
+
202
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
203
+ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
204
+ """
205
+ Check that public Core functions have contracts.
206
+
207
+ Only applies to files marked as Core when require_contracts is True.
208
+
209
+ Examples:
210
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract
211
+ >>> contract = Contract(kind="pre", expression="x > 0", line=1)
212
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[contract])
213
+ >>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
214
+ >>> cfg = RuleConfig(require_contracts=True)
215
+ >>> check_contracts(info, cfg)
216
+ []
217
+ """
218
+ violations: list[Violation] = []
219
+
220
+ if not file_info.is_core or not config.require_contracts:
221
+ return violations
222
+
223
+ for symbol in file_info.symbols:
224
+ # Check all functions and methods - agent needs contracts everywhere
225
+ if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not symbol.contracts:
226
+ kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
227
+ suggestion = format_suggestion_for_violation(symbol, "missing_contract")
228
+ violations.append(
229
+ Violation(
230
+ rule="missing_contract",
231
+ severity=Severity.ERROR,
232
+ file=file_info.path,
233
+ line=symbol.line,
234
+ message=f"{kind_name} '{symbol.name}' has no @pre or @post contract",
235
+ suggestion=suggestion,
236
+ )
237
+ )
238
+
239
+ return violations
240
+
241
+
242
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
243
+ def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
244
+ """
245
+ Check that contracted functions have doctest examples.
246
+
247
+ Only applies to files marked as Core when require_doctests is True.
248
+
249
+ Examples:
250
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract
251
+ >>> contract = Contract(kind="pre", expression="x > 0", line=1)
252
+ >>> sym = Symbol(
253
+ ... name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5,
254
+ ... contracts=[contract], has_doctest=True
255
+ ... )
256
+ >>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym], is_core=True)
257
+ >>> cfg = RuleConfig(require_doctests=True)
258
+ >>> check_doctests(info, cfg)
259
+ []
260
+ """
261
+ violations: list[Violation] = []
262
+
263
+ if not file_info.is_core or not config.require_doctests:
264
+ return violations
265
+
266
+ for symbol in file_info.symbols:
267
+ # Only public functions/methods require doctests (private can skip)
268
+ # For methods, check if method name (after dot) starts with _
269
+ name_part = symbol.name.split(".")[-1] if "." in symbol.name else symbol.name
270
+ is_public = not name_part.startswith("_")
271
+ if (
272
+ symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)
273
+ and is_public
274
+ and symbol.contracts
275
+ and not symbol.has_doctest
276
+ ):
277
+ kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
278
+ violations.append(
279
+ Violation(
280
+ rule="missing_doctest",
281
+ severity=Severity.WARNING,
282
+ file=file_info.path,
283
+ line=symbol.line,
284
+ message=f"{kind_name} '{symbol.name}' has contracts but no doctest examples",
285
+ suggestion="Add >>> examples in docstring",
286
+ )
287
+ )
288
+
289
+ return violations
290
+
291
+
292
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
293
+ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
294
+ """
295
+ Check that Shell functions with return values use Result[T, E].
296
+
297
+ Skips: functions returning None (CLI entry points).
298
+
299
+ Examples:
300
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
301
+ >>> sym = Symbol(name="load", kind=SymbolKind.FUNCTION, line=1, end_line=5,
302
+ ... signature="(path: str) -> Result[str, str]")
303
+ >>> info = FileInfo(path="shell/fs.py", lines=10, symbols=[sym], is_shell=True)
304
+ >>> check_shell_result(info, RuleConfig())
305
+ []
306
+ """
307
+ violations: list[Violation] = []
308
+ if not file_info.is_shell:
309
+ return violations
310
+
311
+ for symbol in file_info.symbols:
312
+ if symbol.kind != SymbolKind.FUNCTION:
313
+ continue
314
+ # Skip functions with no return type or returning None
315
+ if "-> None" in symbol.signature or "->" not in symbol.signature:
316
+ continue
317
+ # Skip generators (Iterator/Generator) - acceptable exception per protocol
318
+ if "Iterator[" in symbol.signature or "Generator[" in symbol.signature:
319
+ continue
320
+ if "Result[" not in symbol.signature:
321
+ violations.append(
322
+ Violation(
323
+ rule="shell_result",
324
+ severity=Severity.WARNING,
325
+ file=file_info.path,
326
+ line=symbol.line,
327
+ message=f"Shell function '{symbol.name}' should return Result[T, E]",
328
+ suggestion="Use Result[T, E] from returns library",
329
+ )
330
+ )
331
+ return violations
332
+
333
+
334
+ @post(lambda result: len(result) > 0)
335
+ def get_all_rules() -> list[RuleFunc]:
336
+ """
337
+ Return all available rule functions.
338
+
339
+ Examples:
340
+ >>> len(get_all_rules()) >= 5
341
+ True
342
+ """
343
+ return [
344
+ check_file_size,
345
+ check_function_size,
346
+ check_forbidden_imports,
347
+ check_contracts,
348
+ check_doctests,
349
+ check_shell_result,
350
+ check_internal_imports,
351
+ check_impure_calls,
352
+ check_empty_contracts,
353
+ check_semantic_tautology,
354
+ check_redundant_type_contracts,
355
+ check_param_mismatch,
356
+ check_partial_contract,
357
+ check_must_use,
358
+ ]
359
+
360
+
361
+ @post(lambda result: result is None or isinstance(result, Violation))
362
+ def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violation | None:
363
+ """
364
+ Apply severity override to a violation.
365
+
366
+ Returns None if rule is set to "off", otherwise returns violation
367
+ with potentially updated severity.
368
+
369
+ Examples:
370
+ >>> from invar.core.models import Violation, Severity
371
+ >>> v = Violation(rule="test", severity=Severity.INFO, file="x.py", message="msg")
372
+ >>> _apply_severity_override(v, {"test": "off"}) is None
373
+ True
374
+ >>> v2 = _apply_severity_override(v, {"test": "error"})
375
+ >>> v2.severity
376
+ <Severity.ERROR: 'error'>
377
+ >>> _apply_severity_override(v, {}).severity # No override
378
+ <Severity.INFO: 'info'>
379
+ """
380
+ override = overrides.get(v.rule)
381
+ if override is None:
382
+ return v
383
+ if override == "off":
384
+ return None
385
+ # Map string to Severity enum
386
+ severity_map = {"info": Severity.INFO, "warning": Severity.WARNING, "error": Severity.ERROR}
387
+ new_severity = severity_map.get(override)
388
+ if new_severity is None:
389
+ return v # Invalid override, keep original
390
+ # Create new violation with updated severity
391
+ return Violation(
392
+ rule=v.rule,
393
+ severity=new_severity,
394
+ file=v.file,
395
+ line=v.line,
396
+ message=v.message,
397
+ suggestion=v.suggestion,
398
+ )
399
+
400
+
401
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
402
+ def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
403
+ """
404
+ Run all rules against a file and collect violations.
405
+
406
+ Respects rule_exclusions and severity_overrides config.
407
+
408
+ Examples:
409
+ >>> from invar.core.models import FileInfo, RuleConfig, RuleExclusion
410
+ >>> violations = check_all_rules(FileInfo(path="test.py", lines=50), RuleConfig())
411
+ >>> isinstance(violations, list)
412
+ True
413
+ >>> # Test exclusion: file_size excluded for generated files
414
+ >>> excl = RuleExclusion(pattern="**/generated/**", rules=["file_size"])
415
+ >>> cfg = RuleConfig(rule_exclusions=[excl])
416
+ >>> big_file = FileInfo(path="src/generated/data.py", lines=600)
417
+ >>> vs = check_all_rules(big_file, cfg)
418
+ >>> any(v.rule == "file_size" for v in vs)
419
+ False
420
+ """
421
+ # Phase 9 P1: Get excluded rules for this file
422
+ excluded = get_excluded_rules(file_info.path, config)
423
+ exclude_all = "*" in excluded
424
+
425
+ violations = []
426
+ for rule in get_all_rules():
427
+ for v in rule(file_info, config):
428
+ # Skip if rule is excluded (either specifically or via "*")
429
+ if exclude_all or v.rule in excluded:
430
+ continue
431
+ # Phase 9 P2: Apply severity overrides
432
+ v = _apply_severity_override(v, config.severity_overrides)
433
+ if v is not None:
434
+ violations.append(v)
435
+ return violations
@@ -0,0 +1,267 @@
1
+ """
2
+ Strategy inference for property-based testing.
3
+
4
+ Core module: parses @pre contract lambdas to infer Hypothesis strategies.
5
+ Inspired by icontract-hypothesis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
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 StrategyHint:
22
+ """Inferred strategy hint for a parameter."""
23
+
24
+ param_name: str
25
+ param_type: type | None
26
+ constraints: dict[str, Any] = field(default_factory=dict)
27
+ description: str = ""
28
+
29
+ @post(lambda result: isinstance(result, dict))
30
+ def to_hypothesis_args(self) -> dict[str, Any]:
31
+ """Convert constraints to Hypothesis strategy arguments.
32
+
33
+ >>> hint = StrategyHint("x", int, {"min_value": 0, "max_value": 100})
34
+ >>> hint.to_hypothesis_args()
35
+ {'min_value': 0, 'max_value': 100}
36
+ """
37
+ return self.constraints.copy()
38
+
39
+
40
+ # Helper to parse numbers (int or float, including scientific notation)
41
+ # Pattern matches valid number literals extracted from regex patterns
42
+ # Uses [0-9] instead of \d to avoid matching Unicode digits
43
+ # Requires at least one digit before optional decimal/exponent parts
44
+ _NUMBER_PATTERN = re.compile(r"^-?[0-9]+\.?[0-9]*(?:e[+-]?[0-9]+)?$", re.IGNORECASE)
45
+
46
+
47
+ @pre(lambda s: isinstance(s, str) and _NUMBER_PATTERN.match(s.strip()))
48
+ @post(lambda result: isinstance(result, (int, float)))
49
+ def _parse_number(s: str) -> int | float:
50
+ """Parse a number string to int or float.
51
+
52
+ >>> _parse_number("42")
53
+ 42
54
+ >>> _parse_number("-3.14")
55
+ -3.14
56
+ >>> _parse_number("1e-5")
57
+ 1e-05
58
+ """
59
+ s = s.strip()
60
+ if "." in s or "e" in s.lower():
61
+ return float(s)
62
+ return int(s)
63
+
64
+
65
+ # Pattern → Constraint extraction
66
+ # Each pattern maps to a function that extracts constraints from regex match
67
+ # DX-12: Added float support with exclude_min/exclude_max for precise bounds
68
+ PATTERNS: list[tuple[str, Callable[[re.Match, str], dict[str, Any] | None]]] = [
69
+ # Numeric comparisons: x > 5, x >= 5 (supports int, float, scientific notation)
70
+ (
71
+ r"(\w+)\s*>\s*(-?[\d.]+(?:e[+-]?\d+)?)",
72
+ lambda m, p: {"min_value": _parse_number(m.group(2)), "exclude_min": True}
73
+ if m.group(1) == p
74
+ else None,
75
+ ),
76
+ (
77
+ r"(\w+)\s*>=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
78
+ lambda m, p: {"min_value": _parse_number(m.group(2))} if m.group(1) == p else None,
79
+ ),
80
+ (
81
+ r"(\w+)\s*<\s*(-?[\d.]+(?:e[+-]?\d+)?)",
82
+ lambda m, p: {"max_value": _parse_number(m.group(2)), "exclude_max": True}
83
+ if m.group(1) == p
84
+ else None,
85
+ ),
86
+ (
87
+ r"(\w+)\s*<=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
88
+ lambda m, p: {"max_value": _parse_number(m.group(2))} if m.group(1) == p else None,
89
+ ),
90
+ # Reversed comparisons: 5 < x, 5 <= x
91
+ (
92
+ r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<\s*(\w+)(?!\s*<)",
93
+ lambda m, p: {"min_value": _parse_number(m.group(1)), "exclude_min": True}
94
+ if m.group(2) == p
95
+ else None,
96
+ ),
97
+ (
98
+ r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<=\s*(\w+)(?!\s*<)",
99
+ lambda m, p: {"min_value": _parse_number(m.group(1))} if m.group(2) == p else None,
100
+ ),
101
+ # Pattern: Range comparison (e.g. 0 < x < 10)
102
+ (
103
+ r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<\s*(\w+)\s*<\s*(-?[\d.]+(?:e[+-]?\d+)?)",
104
+ lambda m, p: {
105
+ "min_value": _parse_number(m.group(1)),
106
+ "max_value": _parse_number(m.group(3)),
107
+ "exclude_min": True,
108
+ "exclude_max": True,
109
+ }
110
+ if m.group(2) == p
111
+ else None,
112
+ ),
113
+ (
114
+ r"(-?[\d.]+(?:e[+-]?\d+)?)\s*<=\s*(\w+)\s*<=\s*(-?[\d.]+(?:e[+-]?\d+)?)",
115
+ lambda m, p: {"min_value": _parse_number(m.group(1)), "max_value": _parse_number(m.group(3))}
116
+ if m.group(2) == p
117
+ else None,
118
+ ),
119
+ # Length constraints: len(x) > 0
120
+ (
121
+ r"len\((\w+)\)\s*>\s*(\d+)",
122
+ lambda m, p: {"min_size": int(m.group(2)) + 1} if m.group(1) == p else None,
123
+ ),
124
+ (
125
+ r"len\((\w+)\)\s*>=\s*(\d+)",
126
+ lambda m, p: {"min_size": int(m.group(2))} if m.group(1) == p else None,
127
+ ),
128
+ (
129
+ r"len\((\w+)\)\s*<\s*(\d+)",
130
+ lambda m, p: {"max_size": int(m.group(2)) - 1} if m.group(1) == p else None,
131
+ ),
132
+ (
133
+ r"len\((\w+)\)\s*<=\s*(\d+)",
134
+ lambda m, p: {"max_size": int(m.group(2))} if m.group(1) == p else None,
135
+ ),
136
+ # Non-empty: len(x) > 0 or just x
137
+ (
138
+ r"len\((\w+)\)\s*>\s*0",
139
+ lambda m, p: {"min_size": 1} if m.group(1) == p else None,
140
+ ),
141
+ ]
142
+
143
+
144
+ @pre(
145
+ lambda pre_source, param_name, param_type=None: isinstance(pre_source, str)
146
+ and isinstance(param_name, str)
147
+ )
148
+ @post(lambda result: isinstance(result, StrategyHint))
149
+ def infer_from_lambda(
150
+ pre_source: str,
151
+ param_name: str,
152
+ param_type: type | None = None,
153
+ ) -> StrategyHint:
154
+ """
155
+ Parse @pre lambda source to infer strategy constraints.
156
+
157
+ DX-12: Now returns exclude_min/exclude_max for strict inequalities,
158
+ allowing Hypothesis to generate precise bounds for floats.
159
+
160
+ >>> hint = infer_from_lambda("lambda x: x > 0", "x", int)
161
+ >>> hint.constraints['min_value']
162
+ 0
163
+ >>> hint.constraints.get('exclude_min')
164
+ True
165
+
166
+ >>> hint = infer_from_lambda("lambda x: 0 < x < 100", "x", float)
167
+ >>> hint.constraints['min_value'], hint.constraints['max_value']
168
+ (0, 100)
169
+ >>> hint.constraints.get('exclude_min'), hint.constraints.get('exclude_max')
170
+ (True, True)
171
+
172
+ >>> hint = infer_from_lambda("lambda x: len(x) > 0", "x", list)
173
+ >>> hint.constraints
174
+ {'min_size': 1}
175
+
176
+ >>> hint = infer_from_lambda("lambda x, y: x > 5 and y < 10", "x", int)
177
+ >>> hint.constraints['min_value']
178
+ 5
179
+ """
180
+ constraints: dict[str, Any] = {}
181
+ hints: list[str] = []
182
+
183
+ for pattern, extractor in PATTERNS:
184
+ for match in re.finditer(pattern, pre_source):
185
+ extracted = extractor(match, param_name)
186
+ if extracted:
187
+ constraints.update(extracted)
188
+ hints.append(f"Matched: {pattern}")
189
+
190
+ description = "; ".join(hints) if hints else "No patterns matched"
191
+
192
+ return StrategyHint(
193
+ param_name=param_name,
194
+ param_type=param_type,
195
+ constraints=constraints,
196
+ description=description,
197
+ )
198
+
199
+
200
+ @pre(
201
+ lambda pre_sources, param_name, param_type=None: isinstance(pre_sources, list)
202
+ and isinstance(param_name, str)
203
+ )
204
+ def infer_from_multiple(
205
+ pre_sources: list[str],
206
+ param_name: str,
207
+ param_type: type | None = None,
208
+ ) -> StrategyHint:
209
+ """
210
+ Combine constraints from multiple @pre contracts.
211
+
212
+ >>> hints = infer_from_multiple(["lambda x: x > 0", "lambda x: x < 100"], "x", float)
213
+ >>> hints.constraints['min_value'], hints.constraints['max_value']
214
+ (0, 100)
215
+ >>> hints.constraints.get('exclude_min'), hints.constraints.get('exclude_max')
216
+ (True, True)
217
+ """
218
+ combined: dict[str, Any] = {}
219
+ descriptions: list[str] = []
220
+
221
+ for source in pre_sources:
222
+ hint = infer_from_lambda(source, param_name, param_type)
223
+ combined.update(hint.constraints)
224
+ if hint.description != "No patterns matched":
225
+ descriptions.append(hint.description)
226
+
227
+ return StrategyHint(
228
+ param_name=param_name,
229
+ param_type=param_type,
230
+ constraints=combined,
231
+ description="; ".join(descriptions) if descriptions else "No patterns matched",
232
+ )
233
+
234
+
235
+ @pre(lambda hint: isinstance(hint, StrategyHint))
236
+ @post(lambda result: isinstance(result, str))
237
+ def format_strategy_hint(hint: StrategyHint) -> str:
238
+ """
239
+ Format a strategy hint as a human-readable string.
240
+
241
+ >>> hint = StrategyHint("x", int, {"min_value": 0, "max_value": 100})
242
+ >>> format_strategy_hint(hint)
243
+ 'x: integers(min_value=0, max_value=100)'
244
+
245
+ >>> hint = StrategyHint("y", float, {"min_value": 0, "exclude_min": True})
246
+ >>> 'floats' in format_strategy_hint(hint)
247
+ True
248
+ """
249
+ if not hint.constraints:
250
+ if hint.param_type:
251
+ return f"{hint.param_name}: from_type({hint.param_type.__name__})"
252
+ return f"{hint.param_name}: <unknown>"
253
+
254
+ type_name = hint.param_type.__name__ if hint.param_type else "unknown"
255
+
256
+ if type_name in ("int", "float"):
257
+ strategy = "integers" if type_name == "int" else "floats"
258
+ args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
259
+ return f"{hint.param_name}: {strategy}({args})"
260
+
261
+ if type_name in ("list", "str", "tuple"):
262
+ strategy = "lists" if type_name == "list" else "text"
263
+ args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
264
+ return f"{hint.param_name}: {strategy}({args})"
265
+
266
+ args = ", ".join(f"{k}={v}" for k, v in hint.constraints.items())
267
+ return f"{hint.param_name}: {args}"