xenfra 0.4.3__py3-none-any.whl → 0.4.5__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.
@@ -0,0 +1,55 @@
1
+ """
2
+ Blueprint factory for framework detection and build plan generation.
3
+
4
+ Implements Railpack-first routing with 3-tier dry-run validation:
5
+ - Tier 1: Local validation (~100ms) - Syntax, imports, config
6
+ - Tier 2: Railpack plan (~1-2s) - Build plan generation
7
+ - Tier 3: E2B Sandbox (~30-60s) - Full build validation via Xenfra API
8
+
9
+ Tier 3 runs on Xenfra's servers - users don't need E2B installed.
10
+
11
+ Environment Variables:
12
+ - XENFRA_AI=off: Disable all AI features
13
+ - XENFRA_SANDBOX=force: Always run Tier 3 validation
14
+ - XENFRA_SANDBOX=skip: Skip Tier 3 validation
15
+ - XENFRA_SANDBOX=auto: Smart detection (default)
16
+ """
17
+
18
+ from .base import Blueprint, BuildPlan
19
+ from .factory import BlueprintFactory, get_factory, detect_project
20
+ from .railpack import RailpackBlueprint
21
+ from .dry_run import (
22
+ DryRunEngine,
23
+ DryRunResult,
24
+ run_dry_run,
25
+ quick_validate,
26
+ )
27
+ from .validation import ValidationResult, validate_project
28
+ from .e2b import E2BSandbox, SandboxResult, get_sandbox
29
+
30
+ __all__ = [
31
+ # Base classes
32
+ "Blueprint",
33
+ "BuildPlan",
34
+
35
+ # Factory
36
+ "BlueprintFactory",
37
+ "get_factory",
38
+ "detect_project",
39
+ "RailpackBlueprint",
40
+
41
+ # 3-Tier Dry Run
42
+ "DryRunEngine",
43
+ "DryRunResult",
44
+ "run_dry_run",
45
+ "quick_validate",
46
+
47
+ # Tier 1
48
+ "ValidationResult",
49
+ "validate_project",
50
+
51
+ # Tier 3
52
+ "E2BSandbox",
53
+ "SandboxResult",
54
+ "get_sandbox",
55
+ ]
@@ -0,0 +1,85 @@
1
+ """
2
+ Base blueprint interface for build plan generation.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class BuildPlan:
13
+ """A build plan containing all information needed to build and deploy."""
14
+
15
+ framework: str
16
+ language: str
17
+ package_manager: str
18
+ dependency_file: str
19
+ build_command: Optional[str] = None
20
+ start_command: Optional[str] = None
21
+ port: int = 8000
22
+ dockerfile: Optional[str] = None
23
+ env_vars: dict = field(default_factory=dict)
24
+ detected_from: str = "unknown" # How the framework was detected
25
+
26
+ def to_dict(self) -> dict:
27
+ """Convert build plan to dictionary."""
28
+ return {
29
+ "framework": self.framework,
30
+ "language": self.language,
31
+ "package_manager": self.package_manager,
32
+ "dependency_file": self.dependency_file,
33
+ "build_command": self.build_command,
34
+ "start_command": self.start_command,
35
+ "port": self.port,
36
+ "dockerfile": self.dockerfile,
37
+ "env_vars": self.env_vars,
38
+ "detected_from": self.detected_from,
39
+ }
40
+
41
+
42
+ class Blueprint(ABC):
43
+ """
44
+ Abstract base class for deployment blueprints.
45
+
46
+ Blueprints analyze project structure and generate build plans.
47
+ """
48
+
49
+ name: str = "base"
50
+ languages: list[str] = []
51
+
52
+ @abstractmethod
53
+ def detect(self, project_path: Path) -> bool:
54
+ """
55
+ Detect if this blueprint applies to the given project.
56
+
57
+ Args:
58
+ project_path: Path to the project directory
59
+
60
+ Returns:
61
+ True if this blueprint can handle the project
62
+ """
63
+ pass
64
+
65
+ @abstractmethod
66
+ def generate_plan(self, project_path: Path) -> BuildPlan:
67
+ """
68
+ Generate a build plan for the project.
69
+
70
+ Args:
71
+ project_path: Path to the project directory
72
+
73
+ Returns:
74
+ BuildPlan with all build/deployment information
75
+ """
76
+ pass
77
+
78
+ def get_priority(self) -> int:
79
+ """
80
+ Get the priority of this blueprint (higher = tried first).
81
+
82
+ Returns:
83
+ Priority integer
84
+ """
85
+ return 100
@@ -0,0 +1,286 @@
1
+ """
2
+ Smart detection and caching for Tier 3 validation decisions.
3
+
4
+ Tracks build state to determine when full sandbox validation is needed.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from dataclasses import dataclass, asdict
14
+
15
+
16
+ @dataclass
17
+ class BuildState:
18
+ """Cached state of a successful build."""
19
+ project_name: str
20
+ framework: str
21
+ language: str
22
+ dependency_hash: str
23
+ config_hash: str
24
+ build_plan_hash: str
25
+ status: str # "success" or "failed"
26
+ timestamp: str
27
+
28
+ def to_dict(self) -> dict:
29
+ return asdict(self)
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: dict) -> "BuildState":
33
+ return cls(**data)
34
+
35
+
36
+ class BuildCache:
37
+ """
38
+ Manages build state cache for smart detection.
39
+
40
+ Stores hashes of key files to detect changes that would
41
+ require re-validation in the sandbox.
42
+ """
43
+
44
+ CACHE_DIR_NAME = ".xenfra"
45
+ CACHE_FILE_NAME = "build_state.json"
46
+
47
+ def __init__(self, project_path: Path):
48
+ self.project_path = project_path
49
+ self.cache_dir = project_path / self.CACHE_DIR_NAME
50
+ self.cache_file = self.cache_dir / self.CACHE_FILE_NAME
51
+
52
+ def get_cached_state(self) -> Optional[BuildState]:
53
+ """
54
+ Get the cached build state if it exists.
55
+
56
+ Returns:
57
+ BuildState if cache exists, None otherwise
58
+ """
59
+ if not self.cache_file.exists():
60
+ return None
61
+
62
+ try:
63
+ content = self.cache_file.read_text()
64
+ data = json.loads(content)
65
+ return BuildState.from_dict(data)
66
+ except (json.JSONDecodeError, KeyError, TypeError):
67
+ return None
68
+
69
+ def save_state(self, state: BuildState):
70
+ """
71
+ Save the build state to cache.
72
+
73
+ Args:
74
+ state: BuildState to cache
75
+ """
76
+ self.cache_dir.mkdir(exist_ok=True)
77
+ # Add to .gitignore if not already there
78
+ self._ensure_gitignore()
79
+
80
+ self.cache_file.write_text(json.dumps(state.to_dict(), indent=2))
81
+
82
+ def _ensure_gitignore(self):
83
+ """Ensure .xenfra is in .gitignore."""
84
+ gitignore = self.project_path / ".gitignore"
85
+ entry = ".xenfra/"
86
+
87
+ if gitignore.exists():
88
+ content = gitignore.read_text()
89
+ if entry not in content:
90
+ with open(gitignore, "a") as f:
91
+ f.write(f"\n{entry}\n")
92
+ else:
93
+ gitignore.write_text(f"{entry}\n")
94
+
95
+ def compute_dependency_hash(self, plan: "BuildPlan") -> str:
96
+ """
97
+ Compute hash of dependency files.
98
+
99
+ Args:
100
+ plan: BuildPlan with dependency information
101
+
102
+ Returns:
103
+ Hash string of dependency files
104
+ """
105
+ hasher = hashlib.sha256()
106
+
107
+ # Main dependency file
108
+ dep_file = self.project_path / plan.dependency_file
109
+ if dep_file.exists():
110
+ hasher.update(dep_file.read_bytes())
111
+
112
+ # Lock files
113
+ lock_files = {
114
+ "python": ["poetry.lock", "uv.lock", "Pipfile.lock"],
115
+ "node": ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"],
116
+ "go": ["go.sum"],
117
+ "rust": ["Cargo.lock"],
118
+ "ruby": ["Gemfile.lock"],
119
+ }
120
+
121
+ for lock in lock_files.get(plan.language, []):
122
+ lock_path = self.project_path / lock
123
+ if lock_path.exists():
124
+ hasher.update(lock_path.read_bytes())
125
+
126
+ return hasher.hexdigest()[:16]
127
+
128
+ def compute_config_hash(self, plan: "BuildPlan") -> str:
129
+ """
130
+ Compute hash of configuration files.
131
+
132
+ Args:
133
+ plan: BuildPlan with configuration information
134
+
135
+ Returns:
136
+ Hash string of config files
137
+ """
138
+ hasher = hashlib.sha256()
139
+
140
+ # xenfra.yaml
141
+ config_file = self.project_path / "xenfra.yaml"
142
+ if config_file.exists():
143
+ hasher.update(config_file.read_bytes())
144
+
145
+ # Dockerfile
146
+ dockerfile = self.project_path / "Dockerfile"
147
+ if dockerfile.exists():
148
+ hasher.update(dockerfile.read_bytes())
149
+
150
+ # Docker Compose
151
+ compose_files = ["docker-compose.yml", "docker-compose.yaml"]
152
+ for cf in compose_files:
153
+ cf_path = self.project_path / cf
154
+ if cf_path.exists():
155
+ hasher.update(cf_path.read_bytes())
156
+
157
+ return hasher.hexdigest()[:16]
158
+
159
+ def compute_build_plan_hash(self, plan: "BuildPlan") -> str:
160
+ """
161
+ Compute hash of the build plan itself.
162
+
163
+ Args:
164
+ plan: BuildPlan to hash
165
+
166
+ Returns:
167
+ Hash string of build plan
168
+ """
169
+ plan_dict = plan.to_dict()
170
+ plan_str = json.dumps(plan_dict, sort_keys=True)
171
+ return hashlib.sha256(plan_str.encode()).hexdigest()[:16]
172
+
173
+
174
+ class SmartDetector:
175
+ """
176
+ Smart detection for Tier 3 (E2B) validation.
177
+
178
+ Determines when full sandbox validation is needed based on
179
+ changes to dependencies, config, or previous build failures.
180
+ """
181
+
182
+ def __init__(self, project_path: Path):
183
+ self.project_path = project_path
184
+ self.cache = BuildCache(project_path)
185
+
186
+ def should_run_sandbox(
187
+ self,
188
+ plan: "BuildPlan",
189
+ force: bool = False
190
+ ) -> tuple[bool, str]:
191
+ """
192
+ Determine if Tier 3 (E2B) validation should run.
193
+
194
+ Args:
195
+ plan: BuildPlan for the project
196
+ force: If True, always run sandbox
197
+
198
+ Returns:
199
+ Tuple of (should_run, reason)
200
+ """
201
+ if force:
202
+ return True, "Forced by --force flag"
203
+
204
+ # Check for environment variable override
205
+ sandbox_mode = os.environ.get("XENFRA_SANDBOX", "auto").lower()
206
+
207
+ if sandbox_mode == "force":
208
+ return True, "Forced by XENFRA_SANDBOX=force"
209
+
210
+ if sandbox_mode == "skip":
211
+ return False, "Skipped by XENFRA_SANDBOX=skip"
212
+
213
+ # Get cached state
214
+ cached = self.cache.get_cached_state()
215
+ if not cached:
216
+ return True, "First deployment - validation required"
217
+
218
+ # Compute current hashes
219
+ current_dep_hash = self.cache.compute_dependency_hash(plan)
220
+ current_config_hash = self.cache.compute_config_hash(plan)
221
+ current_plan_hash = self.cache.compute_build_plan_hash(plan)
222
+
223
+ # Check for failures
224
+ if cached.status == "failed":
225
+ return True, "Previous build failed - re-validation required"
226
+
227
+ # Check for dependency changes
228
+ if current_dep_hash != cached.dependency_hash:
229
+ return True, "Dependencies changed - validation required"
230
+
231
+ # Check for config changes
232
+ if current_config_hash != cached.config_hash:
233
+ return True, "Build configuration changed - validation required"
234
+
235
+ # Check for framework changes
236
+ if plan.framework != cached.framework:
237
+ return True, "Framework changed - validation required"
238
+
239
+ # Check for build plan changes
240
+ if current_plan_hash != cached.build_plan_hash:
241
+ return True, "Build plan changed - validation required"
242
+
243
+ return False, "No significant changes - skipping sandbox"
244
+
245
+ def record_success(self, plan: "BuildPlan"):
246
+ """
247
+ Record a successful build.
248
+
249
+ Args:
250
+ plan: BuildPlan that succeeded
251
+ """
252
+ state = BuildState(
253
+ project_name=self.project_path.name,
254
+ framework=plan.framework,
255
+ language=plan.language,
256
+ dependency_hash=self.cache.compute_dependency_hash(plan),
257
+ config_hash=self.cache.compute_config_hash(plan),
258
+ build_plan_hash=self.cache.compute_build_plan_hash(plan),
259
+ status="success",
260
+ timestamp=datetime.utcnow().isoformat()
261
+ )
262
+ self.cache.save_state(state)
263
+
264
+ def record_failure(self, plan: "BuildPlan"):
265
+ """
266
+ Record a failed build.
267
+
268
+ Args:
269
+ plan: BuildPlan that failed
270
+ """
271
+ state = BuildState(
272
+ project_name=self.project_path.name,
273
+ framework=plan.framework,
274
+ language=plan.language,
275
+ dependency_hash=self.cache.compute_dependency_hash(plan),
276
+ config_hash=self.cache.compute_config_hash(plan),
277
+ build_plan_hash=self.cache.compute_build_plan_hash(plan),
278
+ status="failed",
279
+ timestamp=datetime.utcnow().isoformat()
280
+ )
281
+ self.cache.save_state(state)
282
+
283
+
284
+ def get_detector(project_path: Path) -> SmartDetector:
285
+ """Get smart detector for a project."""
286
+ return SmartDetector(project_path)
@@ -0,0 +1,251 @@
1
+ """
2
+ 3-Tier Dry Run System - "Dry Run First" Architecture
3
+
4
+ Every deployment is validated through 3 tiers:
5
+ - Tier 1: Local validation (~100ms) - Syntax, imports, config
6
+ - Tier 2: Railpack plan (~1-2s) - Build plan generation
7
+ - Tier 3: E2B Sandbox (~30-60s) - Full build validation (smart detection)
8
+
9
+ This ensures deployments never fail due to avoidable errors.
10
+ """
11
+
12
+ from pathlib import Path
13
+ from typing import Optional
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+
17
+ from .base import BuildPlan
18
+ from .validation import validate_project, ValidationResult
19
+ from .e2b import get_sandbox, SandboxResult
20
+ from .cache import get_detector
21
+ from .factory import get_factory
22
+
23
+
24
+ class TierStatus(Enum):
25
+ """Status of a tier validation."""
26
+ PENDING = "pending"
27
+ RUNNING = "running"
28
+ PASSED = "passed"
29
+ FAILED = "failed"
30
+ SKIPPED = "skipped"
31
+
32
+
33
+ @dataclass
34
+ class DryRunResult:
35
+ """Complete result of dry-run validation."""
36
+ success: bool
37
+ tier1: ValidationResult
38
+ tier2: Optional[BuildPlan]
39
+ tier3: Optional[SandboxResult]
40
+ tier3_ran: bool
41
+ tier3_reason: str
42
+ errors: list[str] = field(default_factory=list)
43
+
44
+ @property
45
+ def all_tiers_passed(self) -> bool:
46
+ """Check if all executed tiers passed."""
47
+ if not self.tier1.success:
48
+ return False
49
+ if self.tier2 is None:
50
+ return False
51
+ if self.tier3_ran and (self.tier3 is None or not self.tier3.success):
52
+ return False
53
+ return True
54
+
55
+ def get_summary(self) -> str:
56
+ """Get human-readable summary."""
57
+ lines = ["Dry Run Results:", ""]
58
+
59
+ # Tier 1
60
+ t1_status = "✅ PASSED" if self.tier1.success else "❌ FAILED"
61
+ lines.append(f"Tier 1 (Local): {t1_status}")
62
+ if self.tier1.errors:
63
+ for err in self.tier1.errors:
64
+ lines.append(f" - {err}")
65
+
66
+ # Tier 2
67
+ if self.tier2:
68
+ lines.append(f"Tier 2 (Railpack): ✅ PASSED - {self.tier2.framework}")
69
+ else:
70
+ lines.append("Tier 2 (Railpack): ❌ FAILED")
71
+
72
+ # Tier 3
73
+ if self.tier3_ran:
74
+ if self.tier3 and self.tier3.success:
75
+ lines.append(f"Tier 3 (Sandbox): ✅ PASSED ({self.tier3.build_time_seconds:.1f}s)")
76
+ else:
77
+ lines.append("Tier 3 (Sandbox): ❌ FAILED")
78
+ if self.tier3 and self.tier3.error_message:
79
+ lines.append(f" - {self.tier3.error_message}")
80
+ else:
81
+ lines.append(f"Tier 3 (Sandbox): ⏭️ SKIPPED - {self.tier3_reason}")
82
+
83
+ return "\n".join(lines)
84
+
85
+
86
+ class DryRunEngine:
87
+ """
88
+ 3-Tier Dry Run Engine.
89
+
90
+ Validates deployments through increasingly thorough checks,
91
+ ensuring builds succeed before touching real infrastructure.
92
+ """
93
+
94
+ def __init__(self, project_path: Path, api_client=None, file_manifest=None):
95
+ self.project_path = project_path
96
+ self.file_manifest = file_manifest
97
+ self.factory = get_factory()
98
+ self.sandbox = get_sandbox(api_client)
99
+ self.detector = get_detector(project_path)
100
+
101
+ def run(
102
+ self,
103
+ force_sandbox: bool = False,
104
+ skip_sandbox: bool = False
105
+ ) -> DryRunResult:
106
+ """
107
+ Run full dry-run validation.
108
+
109
+ Args:
110
+ force_sandbox: Always run Tier 3 even if not needed
111
+ skip_sandbox: Never run Tier 3 (for quick iteration)
112
+
113
+ Returns:
114
+ DryRunResult with all tier results
115
+ """
116
+ errors = []
117
+
118
+ # ========== TIER 1: Local Validation ==========
119
+ tier1_result = validate_project(self.project_path)
120
+
121
+ if not tier1_result.success:
122
+ return DryRunResult(
123
+ success=False,
124
+ tier1=tier1_result,
125
+ tier2=None,
126
+ tier3=None,
127
+ tier3_ran=False,
128
+ tier3_reason="Skipped due to Tier 1 failures",
129
+ errors=tier1_result.errors
130
+ )
131
+
132
+ # ========== TIER 2: Railpack Plan ==========
133
+ try:
134
+ tier2_plan = self.factory.get_build_plan(self.project_path)
135
+ except Exception as e:
136
+ errors.append(f"Railpack plan generation failed: {e}")
137
+ return DryRunResult(
138
+ success=False,
139
+ tier1=tier1_result,
140
+ tier2=None,
141
+ tier3=None,
142
+ tier3_ran=False,
143
+ tier3_reason="Skipped due to Tier 2 failure",
144
+ errors=errors
145
+ )
146
+
147
+ # ========== TIER 3: E2B Sandbox ==========
148
+ tier3_result: Optional[SandboxResult] = None
149
+ tier3_ran = False
150
+ tier3_reason = ""
151
+
152
+ if skip_sandbox:
153
+ tier3_reason = "Skipped by user (--skip-sandbox)"
154
+ else:
155
+ # Check if we should run sandbox
156
+ should_run, reason = self.detector.should_run_sandbox(
157
+ tier2_plan,
158
+ force=force_sandbox
159
+ )
160
+ tier3_reason = reason
161
+
162
+ if should_run and self.sandbox.is_available:
163
+ tier3_ran = True
164
+ tier3_result = self.sandbox.validate_build(
165
+ self.project_path,
166
+ tier2_plan,
167
+ file_manifest=self.file_manifest or []
168
+ )
169
+
170
+ # Record result for smart detection
171
+ if tier3_result.success:
172
+ self.detector.record_success(tier2_plan)
173
+ else:
174
+ self.detector.record_failure(tier2_plan)
175
+ errors.append(
176
+ tier3_result.error_message or "Sandbox build failed"
177
+ )
178
+ elif should_run and not self.sandbox.is_available:
179
+ tier3_reason = "Sandbox unavailable (not authenticated)"
180
+
181
+ # Determine overall success
182
+ success = (
183
+ tier1_result.success and
184
+ tier2_plan is not None and
185
+ (not tier3_ran or (tier3_result and tier3_result.success))
186
+ )
187
+
188
+ return DryRunResult(
189
+ success=success,
190
+ tier1=tier1_result,
191
+ tier2=tier2_plan,
192
+ tier3=tier3_result,
193
+ tier3_ran=tier3_ran,
194
+ tier3_reason=tier3_reason,
195
+ errors=errors
196
+ )
197
+
198
+ def quick_check(self) -> ValidationResult:
199
+ """
200
+ Run only Tier 1 validation for quick feedback.
201
+
202
+ Returns:
203
+ ValidationResult from local validation
204
+ """
205
+ return validate_project(self.project_path)
206
+
207
+ def generate_plan(self) -> BuildPlan:
208
+ """
209
+ Generate build plan (Tier 2).
210
+
211
+ Returns:
212
+ BuildPlan from Railpack analysis
213
+ """
214
+ return self.factory.get_build_plan(self.project_path)
215
+
216
+
217
+ def run_dry_run(
218
+ project_path: Path,
219
+ force_sandbox: bool = False,
220
+ skip_sandbox: bool = False,
221
+ api_client=None,
222
+ file_manifest: list = None
223
+ ) -> DryRunResult:
224
+ """
225
+ Convenience function to run dry-run validation.
226
+
227
+ Args:
228
+ project_path: Path to project directory
229
+ force_sandbox: Always run sandbox
230
+ skip_sandbox: Skip sandbox validation
231
+ api_client: Xenfra API client for Tier 3 sandbox
232
+ file_manifest: List of file info dicts for the project
233
+
234
+ Returns:
235
+ DryRunResult with full validation results
236
+ """
237
+ engine = DryRunEngine(project_path, api_client=api_client, file_manifest=file_manifest)
238
+ return engine.run(force_sandbox=force_sandbox, skip_sandbox=skip_sandbox)
239
+
240
+
241
+ def quick_validate(project_path: Path) -> ValidationResult:
242
+ """
243
+ Quick validation (Tier 1 only).
244
+
245
+ Args:
246
+ project_path: Path to project directory
247
+
248
+ Returns:
249
+ ValidationResult
250
+ """
251
+ return validate_project(project_path)