invar-tools 1.0.0__py3-none-any.whl → 1.3.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 (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,393 @@
1
+ """Subprocess environment preparation with PYTHONPATH injection.
2
+
3
+ DX-52: Enable uvx-based invar to access project dependencies.
4
+
5
+ This module provides three phases of dependency injection:
6
+ - Phase 1: PYTHONPATH injection for immediate compatibility
7
+ - Phase 2: Re-spawn detection for perfect compatibility
8
+ - Phase 3: Version mismatch detection for smart upgrade prompts
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+
19
+ from deal import post, pre
20
+
21
+ __all__ = [
22
+ "build_subprocess_env",
23
+ "check_version_mismatch",
24
+ "detect_project_python_with_invar",
25
+ "detect_project_venv",
26
+ "find_site_packages",
27
+ "get_venv_python_version",
28
+ "maybe_show_upgrade_prompt",
29
+ "should_respawn",
30
+ "should_suppress_prompt",
31
+ ]
32
+
33
+
34
+ # =============================================================================
35
+ # Phase 1: PYTHONPATH Injection
36
+ # =============================================================================
37
+
38
+
39
+ VENV_NAMES: tuple[str, ...] = (".venv", "venv", ".env", "env")
40
+
41
+
42
+ @pre(lambda cwd: isinstance(cwd, Path))
43
+ @post(lambda result: result is None or result.exists())
44
+ def detect_project_venv(cwd: Path) -> Path | None:
45
+ """Detect project's virtual environment.
46
+
47
+ Searches for common venv directory names with pyvenv.cfg marker.
48
+
49
+ Args:
50
+ cwd: Current working directory (project root)
51
+
52
+ Returns:
53
+ Path to venv directory, or None if not found
54
+
55
+ Examples:
56
+ >>> from pathlib import Path
57
+ >>> detect_project_venv(Path("/nonexistent")) is None
58
+ True
59
+ """
60
+ for name in VENV_NAMES:
61
+ venv_path = cwd / name
62
+ if (venv_path / "pyvenv.cfg").exists():
63
+ return venv_path
64
+
65
+ return None
66
+
67
+
68
+ # @shell_complexity: Cross-platform venv layout detection (Unix vs Windows)
69
+ @pre(lambda venv_path: isinstance(venv_path, Path))
70
+ @post(lambda result: result is None or result.exists())
71
+ def find_site_packages(venv_path: Path) -> Path | None:
72
+ """Find site-packages directory within a venv.
73
+
74
+ Handles both Unix and Windows layouts.
75
+
76
+ Args:
77
+ venv_path: Path to virtual environment
78
+
79
+ Returns:
80
+ Path to site-packages, or None if not found
81
+
82
+ Examples:
83
+ >>> from pathlib import Path
84
+ >>> find_site_packages(Path("/nonexistent")) is None
85
+ True
86
+ """
87
+ if not venv_path.exists():
88
+ return None
89
+
90
+ # Unix layout: lib/pythonX.Y/site-packages
91
+ lib_path = venv_path / "lib"
92
+ if lib_path.exists():
93
+ for python_dir in lib_path.glob("python*"):
94
+ site_packages = python_dir / "site-packages"
95
+ if site_packages.exists():
96
+ return site_packages
97
+
98
+ # Windows layout: Lib/site-packages
99
+ lib_path_win = venv_path / "Lib" / "site-packages"
100
+ if lib_path_win.exists():
101
+ return lib_path_win
102
+
103
+ return None
104
+
105
+
106
+ # @shell_complexity: Environment construction with optional PYTHONPATH injection
107
+ @post(lambda result: isinstance(result, dict))
108
+ def build_subprocess_env(cwd: Path | None = None) -> dict[str, str]:
109
+ """Build environment dict with project's site-packages in PYTHONPATH.
110
+
111
+ This enables uvx-based invar to import project dependencies
112
+ when running doctests, property tests, and CrossHair.
113
+
114
+ Args:
115
+ cwd: Project root directory (defaults to current directory)
116
+
117
+ Returns:
118
+ Environment dict suitable for subprocess.run(env=...)
119
+
120
+ Examples:
121
+ >>> env = build_subprocess_env()
122
+ >>> isinstance(env, dict)
123
+ True
124
+ >>> "PATH" in env # Inherits from current env
125
+ True
126
+ """
127
+ env = os.environ.copy()
128
+ project_root = cwd or Path.cwd()
129
+
130
+ venv = detect_project_venv(project_root)
131
+ if venv is None:
132
+ return env
133
+
134
+ site_packages = find_site_packages(venv)
135
+ if site_packages is None:
136
+ return env
137
+
138
+ # Prepend to PYTHONPATH (project packages have priority)
139
+ current = env.get("PYTHONPATH", "")
140
+ separator = ";" if os.name == "nt" else ":"
141
+ if current:
142
+ env["PYTHONPATH"] = f"{site_packages}{separator}{current}"
143
+ else:
144
+ env["PYTHONPATH"] = str(site_packages)
145
+
146
+ return env
147
+
148
+
149
+ # =============================================================================
150
+ # Phase 2: Smart Re-spawn
151
+ # =============================================================================
152
+
153
+
154
+ # @shell_complexity: Cross-platform Python detection with subprocess check
155
+ @pre(lambda cwd: isinstance(cwd, Path))
156
+ @post(lambda result: result is None or result.exists())
157
+ def detect_project_python_with_invar(cwd: Path) -> Path | None:
158
+ """Detect project Python that has invar installed.
159
+
160
+ Used by MCP server to decide whether to re-spawn with project Python.
161
+
162
+ Args:
163
+ cwd: Project root directory
164
+
165
+ Returns:
166
+ Path to Python executable if invar is installed, None otherwise
167
+
168
+ Examples:
169
+ >>> from pathlib import Path
170
+ >>> detect_project_python_with_invar(Path("/nonexistent")) is None
171
+ True
172
+ """
173
+ venv = detect_project_venv(cwd)
174
+ if venv is None:
175
+ return None
176
+
177
+ # Find Python executable (Unix vs Windows)
178
+ python_path = venv / "bin" / "python"
179
+ if not python_path.exists():
180
+ python_path = venv / "Scripts" / "python.exe"
181
+ if not python_path.exists():
182
+ return None
183
+
184
+ # Check if invar is installed in this venv
185
+ try:
186
+ result = subprocess.run(
187
+ [str(python_path), "-c", "import invar"],
188
+ capture_output=True,
189
+ timeout=5,
190
+ )
191
+ if result.returncode == 0:
192
+ return python_path
193
+ except (subprocess.TimeoutExpired, OSError):
194
+ pass
195
+
196
+ return None
197
+
198
+
199
+ @pre(lambda cwd: isinstance(cwd, Path))
200
+ def should_respawn(cwd: Path) -> tuple[bool, Path | None]:
201
+ """Check if MCP server should re-spawn with project Python.
202
+
203
+ Returns:
204
+ (should_respawn, project_python_path)
205
+
206
+ Examples:
207
+ >>> from pathlib import Path
208
+ >>> should, python = should_respawn(Path("/nonexistent"))
209
+ >>> should
210
+ False
211
+ """
212
+ project_python = detect_project_python_with_invar(cwd)
213
+
214
+ if project_python is None:
215
+ return (False, None)
216
+
217
+ # Don't respawn if already running with project Python
218
+ if str(project_python.resolve()) == str(Path(sys.executable).resolve()):
219
+ return (False, None)
220
+
221
+ return (True, project_python)
222
+
223
+
224
+ # =============================================================================
225
+ # Phase 3: Smart Upgrade Prompt
226
+ # =============================================================================
227
+
228
+
229
+ # @shell_complexity: Config file parsing with error handling
230
+ @pre(lambda venv_path: isinstance(venv_path, Path))
231
+ def get_venv_python_version(venv_path: Path) -> tuple[int, int] | None:
232
+ """Read Python version from venv's pyvenv.cfg.
233
+
234
+ Avoids spawning a subprocess by parsing the config file directly.
235
+
236
+ Args:
237
+ venv_path: Path to virtual environment
238
+
239
+ Returns:
240
+ (major, minor) version tuple, or None if not found
241
+
242
+ Examples:
243
+ >>> from pathlib import Path
244
+ >>> get_venv_python_version(Path("/nonexistent")) is None
245
+ True
246
+ """
247
+ cfg_path = venv_path / "pyvenv.cfg"
248
+ if not cfg_path.exists():
249
+ return None
250
+
251
+ try:
252
+ for line in cfg_path.read_text().splitlines():
253
+ # Look for "version = X.Y.Z" or "version_info = X.Y.Z"
254
+ if line.startswith("version"):
255
+ # version = 3.11.5 or version_info = 3.11.5
256
+ parts = line.split("=")
257
+ if len(parts) != 2:
258
+ continue
259
+ version_str = parts[1].strip()
260
+ version_parts = version_str.split(".")
261
+ if len(version_parts) >= 2:
262
+ return (int(version_parts[0]), int(version_parts[1]))
263
+ except (ValueError, OSError):
264
+ pass
265
+
266
+ return None
267
+
268
+
269
+ @pre(lambda cwd: isinstance(cwd, Path))
270
+ def check_version_mismatch(cwd: Path) -> tuple[bool, str]:
271
+ """Check if Python versions mismatch between venv and current interpreter.
272
+
273
+ Args:
274
+ cwd: Project root directory
275
+
276
+ Returns:
277
+ (is_mismatched, warning_message)
278
+
279
+ Examples:
280
+ >>> from pathlib import Path
281
+ >>> mismatch, msg = check_version_mismatch(Path("/nonexistent"))
282
+ >>> mismatch
283
+ False
284
+ """
285
+ venv = detect_project_venv(cwd)
286
+ if venv is None:
287
+ return (False, "")
288
+
289
+ venv_version = get_venv_python_version(venv)
290
+ if venv_version is None:
291
+ return (False, "")
292
+
293
+ current_version = (sys.version_info.major, sys.version_info.minor)
294
+
295
+ if venv_version != current_version:
296
+ msg = f"""
297
+ [yellow]Python version mismatch detected[/yellow]
298
+ Project venv: {venv_version[0]}.{venv_version[1]}
299
+ uvx invar: {current_version[0]}.{current_version[1]}
300
+
301
+ C extension modules (numpy, pandas, etc.) may fail to load.
302
+
303
+ To fix, install invar in your project:
304
+ [cyan]pip install invar-tools[/cyan]
305
+
306
+ This enables automatic Python version matching.
307
+ """
308
+ return (True, msg)
309
+
310
+ return (False, "")
311
+
312
+
313
+ # @shell_complexity: File system checks with timestamp handling
314
+ @pre(lambda project_root: isinstance(project_root, Path))
315
+ def should_suppress_prompt(project_root: Path) -> bool:
316
+ """Check if upgrade prompt should be suppressed (pure check, no side effects).
317
+
318
+ Strategies:
319
+ - Per-project daily limit (avoid spam)
320
+ - User can permanently disable via .invar/no-upgrade-prompt
321
+
322
+ Args:
323
+ project_root: Project root directory
324
+
325
+ Returns:
326
+ True if prompt should be suppressed
327
+
328
+ Examples:
329
+ >>> from pathlib import Path
330
+ >>> should_suppress_prompt(Path("/nonexistent"))
331
+ False
332
+ """
333
+ invar_dir = project_root / ".invar"
334
+
335
+ # Permanent disable file
336
+ if (invar_dir / "no-upgrade-prompt").exists():
337
+ return True
338
+
339
+ # Daily limit per project
340
+ marker = invar_dir / ".last-upgrade-prompt"
341
+ if marker.exists():
342
+ try:
343
+ last_time = datetime.fromtimestamp(marker.stat().st_mtime)
344
+ if datetime.now() - last_time < timedelta(days=1):
345
+ return True
346
+ except OSError:
347
+ pass
348
+
349
+ return False
350
+
351
+
352
+ def _update_prompt_marker(project_root: Path) -> None:
353
+ """Update the prompt marker timestamp (called after showing prompt).
354
+
355
+ Args:
356
+ project_root: Project root directory
357
+ """
358
+ invar_dir = project_root / ".invar"
359
+ marker = invar_dir / ".last-upgrade-prompt"
360
+ try:
361
+ invar_dir.mkdir(exist_ok=True)
362
+ marker.touch()
363
+ except OSError:
364
+ pass
365
+
366
+
367
+ @pre(lambda project_root, console: isinstance(project_root, Path))
368
+ def maybe_show_upgrade_prompt(project_root: Path, console: object) -> None:
369
+ """Show upgrade prompt if conditions are met.
370
+
371
+ Args:
372
+ project_root: Project root directory
373
+ console: Rich console for output
374
+
375
+ Examples:
376
+ >>> from pathlib import Path
377
+ >>> # No-op for non-existent paths
378
+ >>> maybe_show_upgrade_prompt(Path("/nonexistent"), None)
379
+ """
380
+ is_mismatched, msg = check_version_mismatch(project_root)
381
+
382
+ if not is_mismatched:
383
+ return # Versions match, no prompt needed
384
+
385
+ if should_suppress_prompt(project_root):
386
+ return # Already prompted recently
387
+
388
+ # Update marker before showing (prevents spam on failures)
389
+ _update_prompt_marker(project_root)
390
+
391
+ # Print warning if console is available
392
+ if console is not None and hasattr(console, "print"):
393
+ console.print(msg)