invar-tools 1.2.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 (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -18,23 +18,22 @@ from typing import TYPE_CHECKING
18
18
  from returns.result import Failure, Result, Success
19
19
  from rich.console import Console
20
20
 
21
- # DX-13: Cache module extracted for file size compliance
22
- from invar.shell.prove_cache import ProveCache # noqa: TC001 - runtime usage
21
+ from invar.shell.prove.cache import ProveCache # noqa: TC001 - runtime usage
23
22
 
24
- # DX-12: Hypothesis fallback (extracted to prove_fallback.py for file size compliance)
25
- from invar.shell.prove_fallback import (
23
+ # DX-12: Hypothesis fallback
24
+ from invar.shell.prove.hypothesis import (
26
25
  run_hypothesis_fallback as run_hypothesis_fallback,
27
26
  )
28
- from invar.shell.prove_fallback import (
27
+ from invar.shell.prove.hypothesis import (
29
28
  run_prove_with_fallback as run_prove_with_fallback,
30
29
  )
30
+ from invar.shell.subprocess_env import build_subprocess_env # DX-52
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from typing import Any
34
34
 
35
35
  console = Console()
36
36
 
37
-
38
37
  # ============================================================
39
38
  # CrossHair Status Codes
40
39
  # ============================================================
@@ -59,17 +58,7 @@ class CrossHairStatus:
59
58
  # @shell_orchestration: Contract detection for CrossHair prove module
60
59
  # @shell_complexity: AST traversal for contract detection
61
60
  def has_verifiable_contracts(source: str) -> bool:
62
- """
63
- Check if source has verifiable contracts.
64
-
65
- DX-13: Hybrid detection - fast string check + AST validation.
66
-
67
- Args:
68
- source: Python source code
69
-
70
- Returns:
71
- True if file has @pre/@post contracts worth verifying
72
- """
61
+ """Check if source has @pre/@post contracts (DX-13: fast string + AST check)."""
73
62
  # Fast path: no contract keywords at all
74
63
  if "@pre" not in source and "@post" not in source:
75
64
  return False
@@ -111,6 +100,8 @@ def has_verifiable_contracts(source: str) -> bool:
111
100
  def _verify_single_file(
112
101
  file_path: str,
113
102
  max_iterations: int = 5,
103
+ timeout: int = 300,
104
+ per_condition_timeout: int = 30,
114
105
  ) -> dict[str, Any]:
115
106
  """
116
107
  Verify a single file with CrossHair.
@@ -120,6 +111,8 @@ def _verify_single_file(
120
111
  Args:
121
112
  file_path: Path to Python file
122
113
  max_iterations: Maximum uninteresting iterations (default: 5)
114
+ timeout: Max time per file in seconds (default: 300)
115
+ per_condition_timeout: Max time per contract in seconds (default: 30)
123
116
 
124
117
  Returns:
125
118
  Verification result dict
@@ -135,15 +128,18 @@ def _verify_single_file(
135
128
  "check",
136
129
  file_path,
137
130
  f"--max_uninteresting_iterations={max_iterations}",
131
+ f"--per_condition_timeout={per_condition_timeout}",
138
132
  "--analysis_kind=deal",
139
133
  ]
140
134
 
141
135
  try:
136
+ # DX-52: Inject project venv site-packages for uvx compatibility
142
137
  result = subprocess.run(
143
138
  cmd,
144
139
  capture_output=True,
145
140
  text=True,
146
- timeout=300, # 5 minute max per file
141
+ timeout=timeout,
142
+ env=build_subprocess_env(),
147
143
  )
148
144
 
149
145
  elapsed_ms = int((time.time() - start_time) * 1000)
@@ -159,14 +155,17 @@ def _verify_single_file(
159
155
  # Check if this is an execution error vs actual counterexample
160
156
  # CrossHair reports TypeError/AttributeError when it can't
161
157
  # symbolically execute C extensions like ast.parse()
162
- stdout = result.stdout
158
+ # Check both stdout and stderr for error patterns
159
+ output = result.stdout + "\n" + result.stderr
163
160
  execution_errors = [
164
161
  "TypeError:",
165
162
  "AttributeError:",
166
163
  "NotImplementedError:",
167
164
  "compile() arg 1 must be", # ast.parse limitation
165
+ "ValueError: wrong parameter order", # CrossHair signature bug
166
+ "ValueError: cannot determine truth", # Symbolic execution limit
168
167
  ]
169
- is_execution_error = any(err in stdout for err in execution_errors)
168
+ is_execution_error = any(err in output for err in execution_errors)
170
169
 
171
170
  if is_execution_error:
172
171
  # Treat as skipped - function uses unsupported operations
@@ -174,15 +173,15 @@ def _verify_single_file(
174
173
  "file": file_path,
175
174
  "status": CrossHairStatus.SKIPPED,
176
175
  "time_ms": elapsed_ms,
177
- "reason": "uses unsupported operations (ast/compile)",
178
- "stdout": stdout,
176
+ "reason": "uses unsupported operations (ast/compile/signature)",
177
+ "stdout": output,
179
178
  }
180
179
 
181
180
  # Extract counterexample lines - CrossHair format: "file:line: error: Err when calling func(...)"
182
181
  # Include lines with "error:" as they contain the actual counterexamples
183
182
  counterexamples = [
184
183
  line.strip()
185
- for line in stdout.split("\n")
184
+ for line in output.split("\n")
186
185
  if line.strip() and ": error:" in line.lower()
187
186
  ]
188
187
  return {
@@ -190,14 +189,14 @@ def _verify_single_file(
190
189
  "status": CrossHairStatus.COUNTEREXAMPLE,
191
190
  "time_ms": elapsed_ms,
192
191
  "counterexamples": counterexamples,
193
- "stdout": stdout,
192
+ "stdout": output,
194
193
  }
195
194
 
196
195
  except subprocess.TimeoutExpired:
197
196
  return {
198
197
  "file": file_path,
199
198
  "status": CrossHairStatus.TIMEOUT,
200
- "time_ms": 300000,
199
+ "time_ms": timeout * 1000,
201
200
  }
202
201
  except Exception as e:
203
202
  return {
@@ -218,17 +217,18 @@ def run_crosshair_parallel(
218
217
  max_iterations: int = 5,
219
218
  max_workers: int | None = None,
220
219
  cache: ProveCache | None = None,
220
+ timeout: int = 300,
221
+ per_condition_timeout: int = 30,
221
222
  ) -> Result[dict, str]:
222
- """
223
- Run CrossHair on multiple files in parallel.
224
-
225
- DX-13: Parallel execution with caching support.
223
+ """Run CrossHair on multiple files in parallel (DX-13).
226
224
 
227
225
  Args:
228
226
  files: List of Python file paths to verify
229
227
  max_iterations: Maximum uninteresting iterations per condition
230
228
  max_workers: Number of parallel workers (default: CPU count)
231
229
  cache: Optional verification cache
230
+ timeout: Max time per file in seconds (default: 300)
231
+ per_condition_timeout: Max time per contract in seconds (default: 30)
232
232
 
233
233
  Returns:
234
234
  Success with verification results or Failure with error message
@@ -327,7 +327,9 @@ def run_crosshair_parallel(
327
327
  # Parallel execution
328
328
  with ProcessPoolExecutor(max_workers=max_workers) as executor:
329
329
  futures = {
330
- executor.submit(_verify_single_file, str(f), max_iterations): f
330
+ executor.submit(
331
+ _verify_single_file, str(f), max_iterations, timeout, per_condition_timeout
332
+ ): f
331
333
  for f in files_to_verify
332
334
  }
333
335
 
@@ -349,7 +351,9 @@ def run_crosshair_parallel(
349
351
  else:
350
352
  # Sequential execution (single file or max_workers=1)
351
353
  for py_file in files_to_verify:
352
- result = _verify_single_file(str(py_file), max_iterations)
354
+ result = _verify_single_file(
355
+ str(py_file), max_iterations, timeout, per_condition_timeout
356
+ )
353
357
  _process_verification_result(
354
358
  result,
355
359
  py_file,
@@ -419,7 +423,7 @@ def _process_verification_result(
419
423
 
420
424
 
421
425
  def run_crosshair_on_files(
422
- files: list[Path], timeout: int = 10
426
+ files: list[Path], timeout: int = 300, per_condition_timeout: int = 30
423
427
  ) -> Result[dict, str]:
424
428
  """
425
429
  Run CrossHair symbolic verification on a list of Python files.
@@ -428,7 +432,8 @@ def run_crosshair_on_files(
428
432
 
429
433
  Args:
430
434
  files: List of Python file paths to verify
431
- timeout: Ignored (kept for backwards compatibility)
435
+ timeout: Max time per file in seconds (default: 300)
436
+ per_condition_timeout: Max time per contract in seconds (default: 30)
432
437
 
433
438
  Returns:
434
439
  Success with verification results or Failure with error message
@@ -439,6 +444,8 @@ def run_crosshair_on_files(
439
444
  max_iterations=5, # Fast mode
440
445
  max_workers=None, # Auto-detect
441
446
  cache=None, # No cache for basic API
447
+ timeout=timeout,
448
+ per_condition_timeout=per_condition_timeout,
442
449
  )
443
450
 
444
451
 
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from returns.result import Failure, Result, Success
19
19
 
20
20
  from invar.core.verification_routing import get_incompatible_imports
21
+ from invar.shell.subprocess_env import build_subprocess_env
21
22
 
22
23
 
23
24
  @dataclass
@@ -84,7 +85,7 @@ def run_hypothesis_fallback(
84
85
  Success with test results or Failure with error message
85
86
  """
86
87
  # Import CrossHairStatus here to avoid circular import
87
- from invar.shell.prove import CrossHairStatus
88
+ from invar.shell.prove.crosshair import CrossHairStatus
88
89
 
89
90
  # Check if hypothesis is available
90
91
  try:
@@ -134,7 +135,14 @@ def run_hypothesis_fallback(
134
135
  cmd.extend(str(f) for f in py_files)
135
136
 
136
137
  try:
137
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
138
+ # DX-52: Inject project venv site-packages for uvx compatibility
139
+ result = subprocess.run(
140
+ cmd,
141
+ capture_output=True,
142
+ text=True,
143
+ timeout=300,
144
+ env=build_subprocess_env(),
145
+ )
138
146
  # Pytest exit codes: 0=passed, 5=no tests collected
139
147
  is_passed = result.returncode in (0, 5)
140
148
  return Success(
@@ -186,8 +194,8 @@ def run_prove_with_fallback(
186
194
  Success with verification results including routing statistics
187
195
  """
188
196
  # Import here to avoid circular import
189
- from invar.shell.prove import CrossHairStatus, run_crosshair_parallel
190
- from invar.shell.prove_cache import ProveCache
197
+ from invar.shell.prove.cache import ProveCache
198
+ from invar.shell.prove.crosshair import CrossHairStatus, run_crosshair_parallel
191
199
 
192
200
  # DX-22: Smart routing - classify files before verification
193
201
  routing = classify_files_for_verification(files)
@@ -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)