xenfra 0.4.4__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,101 @@
1
+ """
2
+ Tier 3: E2B Sandbox integration via Xenfra API.
3
+
4
+ The sandbox runs on Xenfra's servers, not locally. Users don't need:
5
+ - E2B package installed
6
+ - E2B_API_KEY set
7
+ - Any E2B configuration
8
+
9
+ They just need a Xenfra account. We handle E2B on our backend.
10
+ """
11
+
12
+ from pathlib import Path
13
+ from typing import Optional
14
+ from dataclasses import dataclass
15
+
16
+ from .base import BuildPlan
17
+
18
+
19
+ @dataclass
20
+ class SandboxResult:
21
+ """Result of sandbox build validation."""
22
+ success: bool
23
+ build_output: str = ""
24
+ error_message: Optional[str] = None
25
+ build_time_seconds: float = 0.0
26
+
27
+ def __bool__(self):
28
+ return self.success
29
+
30
+
31
+ class E2BSandbox:
32
+ """
33
+ E2B Sandbox client - calls Xenfra API to run sandbox.
34
+
35
+ The actual sandbox runs on Xenfra's servers using E2B.
36
+ This class just makes API calls to our backend via SDK.
37
+ """
38
+
39
+ def __init__(self, api_client=None):
40
+ self.api_client = api_client
41
+
42
+ @property
43
+ def is_available(self) -> bool:
44
+ """Check if sandbox is available (has API client)."""
45
+ return self.api_client is not None
46
+
47
+ def validate_build(
48
+ self,
49
+ project_path: Path,
50
+ plan: BuildPlan,
51
+ file_manifest: list,
52
+ timeout: int = 300
53
+ ) -> SandboxResult:
54
+ """
55
+ Validate a build via Xenfra API sandbox.
56
+
57
+ Args:
58
+ project_path: Path to the project directory (for reference)
59
+ plan: BuildPlan with build information
60
+ file_manifest: List of file info dicts for the project
61
+ timeout: Maximum time to wait
62
+
63
+ Returns:
64
+ SandboxResult with build status
65
+ """
66
+ if not self.api_client:
67
+ return SandboxResult(
68
+ success=False,
69
+ error_message="Not authenticated. Run 'xenfra auth login' first."
70
+ )
71
+
72
+ try:
73
+ # Use SDK's build.validate method
74
+ result = self.api_client.build.validate(
75
+ file_manifest=file_manifest,
76
+ build_plan=plan.to_dict(),
77
+ timeout=timeout
78
+ )
79
+
80
+ return SandboxResult(
81
+ success=result.get("success", False),
82
+ build_output=result.get("output", ""),
83
+ error_message=result.get("error"),
84
+ build_time_seconds=result.get("build_time", 0.0)
85
+ )
86
+
87
+ except Exception as e:
88
+ return SandboxResult(
89
+ success=False,
90
+ error_message=f"Sandbox validation failed: {e}"
91
+ )
92
+
93
+
94
+ def get_sandbox(api_client=None) -> E2BSandbox:
95
+ """Get E2B sandbox client."""
96
+ return E2BSandbox(api_client)
97
+
98
+
99
+ def is_sandbox_available(api_client=None) -> bool:
100
+ """Check if sandbox is available."""
101
+ return get_sandbox(api_client).is_available
@@ -0,0 +1,113 @@
1
+ """
2
+ Blueprint factory - Railpack-first routing with E2B dry-run support.
3
+
4
+ Architecture:
5
+ - Railpack-first: Use Railway's buildpack for all project detection
6
+ - E2B sandbox: Optional dry-run builds in isolated environments
7
+ - Fallback chain: Railpack → Error (no other blueprints needed)
8
+
9
+ Environment Variables:
10
+ - E2B_API_KEY: API key for E2B sandbox (optional, for --dry-run)
11
+ """
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .base import Blueprint, BuildPlan
18
+ from .railpack import RailpackBlueprint
19
+
20
+
21
+ class BlueprintFactory:
22
+ """
23
+ Factory for creating and selecting blueprints.
24
+
25
+ Implements Railpack-first routing:
26
+ 1. RailpackBlueprint - Universal detection for all frameworks
27
+ 2. No fallbacks needed - Railpack handles everything
28
+ """
29
+
30
+ def __init__(self):
31
+ # Single blueprint - Railpack handles all cases
32
+ self._blueprint = RailpackBlueprint()
33
+
34
+ def detect_framework(self, project_path: Path) -> tuple[str, BuildPlan]:
35
+ """
36
+ Detect framework and generate build plan for a project.
37
+
38
+ Args:
39
+ project_path: Path to the project directory
40
+
41
+ Returns:
42
+ Tuple of (framework_name, build_plan)
43
+
44
+ Raises:
45
+ RuntimeError: If detection fails
46
+ """
47
+ plan = self._blueprint.generate_plan(project_path)
48
+ return plan.framework, plan
49
+
50
+ def get_build_plan(self, project_path: Path) -> BuildPlan:
51
+ """
52
+ Get the build plan for a project.
53
+
54
+ Args:
55
+ project_path: Path to the project directory
56
+
57
+ Returns:
58
+ BuildPlan with all build/deployment information
59
+ """
60
+ return self._blueprint.generate_plan(project_path)
61
+
62
+ def generate_dockerfile(self, project_path: Path) -> str:
63
+ """
64
+ Generate a Dockerfile for the project.
65
+
66
+ Args:
67
+ project_path: Path to the project directory
68
+
69
+ Returns:
70
+ Generated Dockerfile content
71
+ """
72
+ return self._blueprint.generate_dockerfile(project_path)
73
+
74
+
75
+ # Global factory instance
76
+ _factory: Optional[BlueprintFactory] = None
77
+
78
+
79
+ def get_factory() -> BlueprintFactory:
80
+ """Get the global blueprint factory instance."""
81
+ global _factory
82
+ if _factory is None:
83
+ _factory = BlueprintFactory()
84
+ return _factory
85
+
86
+
87
+ def get_blueprint_for_framework(framework: str) -> Blueprint:
88
+ """
89
+ Get the appropriate blueprint for a framework.
90
+
91
+ Note: Since we use Railpack-first, this always returns RailpackBlueprint.
92
+ The framework parameter is kept for API compatibility.
93
+
94
+ Args:
95
+ framework: Framework name (for compatibility, not used)
96
+
97
+ Returns:
98
+ RailpackBlueprint instance
99
+ """
100
+ return RailpackBlueprint()
101
+
102
+
103
+ def detect_project(project_path: Path) -> tuple[str, BuildPlan]:
104
+ """
105
+ Convenience function to detect framework and get build plan.
106
+
107
+ Args:
108
+ project_path: Path to the project directory
109
+
110
+ Returns:
111
+ Tuple of (framework_name, build_plan)
112
+ """
113
+ return get_factory().detect_framework(project_path)
@@ -0,0 +1,319 @@
1
+ """
2
+ Railpack blueprint - Railway's buildpack for universal project detection.
3
+
4
+ Downloads and runs the Railpack binary to analyze projects and generate
5
+ Dockerfile-based build plans.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import platform
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from .base import Blueprint, BuildPlan
17
+
18
+
19
+ class RailpackBlueprint(Blueprint):
20
+ """
21
+ Railpack-based blueprint using Railway's buildpack.
22
+
23
+ Railpack is a universal buildpack that can detect and build:
24
+ - Python (FastAPI, Flask, Django, etc.)
25
+ - Node.js (Next.js, Express, NestJS, etc.)
26
+ - Go (Gin, Echo, etc.)
27
+ - Rust (Actix, Axum, etc.)
28
+ - Ruby, PHP, Java, and more
29
+
30
+ Download URL: https://github.com/railwayapp/railpack/releases
31
+ """
32
+
33
+ name = "railpack"
34
+ languages = ["python", "node", "go", "rust", "ruby", "php", "java", "dotnet"]
35
+
36
+ # Version to download
37
+ RAILPACK_VERSION = "0.0.70"
38
+
39
+ def __init__(self):
40
+ self._binary_path: Optional[Path] = None
41
+ self._cache_dir = Path.home() / ".xenfra" / "cache" / "railpack"
42
+
43
+ def _get_binary_name(self) -> str:
44
+ """Get the appropriate binary name for the current platform."""
45
+ system = platform.system().lower()
46
+ machine = platform.machine().lower()
47
+
48
+ # Map machine arch
49
+ if machine in ("amd64", "x86_64"):
50
+ arch = "amd64"
51
+ elif machine in ("arm64", "aarch64"):
52
+ arch = "arm64"
53
+ else:
54
+ arch = "amd64" # fallback
55
+
56
+ # Map system
57
+ if system == "darwin":
58
+ os_name = "darwin"
59
+ elif system == "linux":
60
+ os_name = "linux"
61
+ elif system == "windows":
62
+ os_name = "windows"
63
+ else:
64
+ os_name = "linux" # fallback
65
+
66
+ ext = ".exe" if os_name == "windows" else ""
67
+ return f"railpack_{self.RAILPACK_VERSION}_{os_name}_{arch}{ext}"
68
+
69
+ def _get_download_url(self) -> str:
70
+ """Get the download URL for the current platform."""
71
+ binary_name = self._get_binary_name()
72
+ return (
73
+ f"https://github.com/railwayapp/railpack/releases/download/"
74
+ f"v{self.RAILPACK_VERSION}/{binary_name}"
75
+ )
76
+
77
+ def _ensure_binary(self) -> Path:
78
+ """
79
+ Ensure the Railpack binary is available, download if needed.
80
+
81
+ Returns:
82
+ Path to the railpack binary
83
+ """
84
+ if self._binary_path and self._binary_path.exists():
85
+ return self._binary_path
86
+
87
+ # Create cache directory
88
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ binary_name = self._get_binary_name()
91
+ binary_path = self._cache_dir / "railpack"
92
+
93
+ # Check if already cached
94
+ if binary_path.exists():
95
+ self._binary_path = binary_path
96
+ return binary_path
97
+
98
+ # Download binary
99
+ import urllib.request
100
+
101
+ url = self._get_download_url()
102
+ temp_path = self._cache_dir / binary_name
103
+
104
+ print(f"Downloading Railpack v{self.RAILPACK_VERSION}...")
105
+ print(f" URL: {url}")
106
+
107
+ try:
108
+ urllib.request.urlretrieve(url, temp_path)
109
+
110
+ # Make executable (not needed on Windows)
111
+ if platform.system() != "Windows":
112
+ os.chmod(temp_path, 0o755)
113
+
114
+ # Rename to standard name
115
+ temp_path.rename(binary_path)
116
+
117
+ print(f" Saved to: {binary_path}")
118
+
119
+ self._binary_path = binary_path
120
+ return binary_path
121
+
122
+ except Exception as e:
123
+ raise RuntimeError(f"Failed to download Railpack: {e}")
124
+
125
+ def detect(self, project_path: Path) -> bool:
126
+ """
127
+ Railpack can handle any project - it's universal.
128
+
129
+ Args:
130
+ project_path: Path to the project directory
131
+
132
+ Returns:
133
+ Always True (fallback blueprint)
134
+ """
135
+ return True
136
+
137
+ def generate_plan(self, project_path: Path) -> BuildPlan:
138
+ """
139
+ Generate a build plan using Railpack analysis.
140
+
141
+ Args:
142
+ project_path: Path to the project directory
143
+
144
+ Returns:
145
+ BuildPlan from Railpack analysis
146
+
147
+ Raises:
148
+ RuntimeError: If Railpack analysis fails
149
+ """
150
+ binary = self._ensure_binary()
151
+
152
+ # Run railpack analyze
153
+ try:
154
+ result = subprocess.run(
155
+ [str(binary), "analyze", str(project_path)],
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=60,
159
+ )
160
+
161
+ if result.returncode != 0:
162
+ raise RuntimeError(f"Railpack analyze failed: {result.stderr}")
163
+
164
+ # Parse the output as JSON
165
+ analysis = json.loads(result.stdout)
166
+
167
+ # Extract information from Railpack output
168
+ metadata = analysis.get("metadata", {})
169
+
170
+ # Detect framework from metadata
171
+ framework = self._detect_framework(metadata, project_path)
172
+
173
+ # Get package manager
174
+ pkg_manager = metadata.get("package_manager", "unknown")
175
+
176
+ # Get dependency file
177
+ dep_file = self._get_dependency_file(pkg_manager, project_path)
178
+
179
+ # Get port (common defaults)
180
+ port = self._detect_port(metadata, framework)
181
+
182
+ # Get start command from Railpack or infer
183
+ start_cmd = self._get_start_command(analysis, framework, port)
184
+
185
+ return BuildPlan(
186
+ framework=framework,
187
+ language=metadata.get("language", "unknown"),
188
+ package_manager=pkg_manager,
189
+ dependency_file=dep_file,
190
+ build_command=None, # Railpack handles this in the Dockerfile
191
+ start_command=start_cmd,
192
+ port=port,
193
+ dockerfile=None, # Generated by Railpack
194
+ detected_from="railpack",
195
+ )
196
+
197
+ except json.JSONDecodeError as e:
198
+ raise RuntimeError(f"Failed to parse Railpack output: {e}")
199
+ except subprocess.TimeoutExpired:
200
+ raise RuntimeError("Railpack analysis timed out")
201
+ except Exception as e:
202
+ raise RuntimeError(f"Railpack analysis failed: {e}")
203
+
204
+ def generate_dockerfile(self, project_path: Path) -> str:
205
+ """
206
+ Generate a Dockerfile using Railpack.
207
+
208
+ Args:
209
+ project_path: Path to the project directory
210
+
211
+ Returns:
212
+ Generated Dockerfile content
213
+ """
214
+ binary = self._ensure_binary()
215
+
216
+ try:
217
+ result = subprocess.run(
218
+ [str(binary), "build", str(project_path)],
219
+ capture_output=True,
220
+ text=True,
221
+ timeout=120,
222
+ )
223
+
224
+ if result.returncode != 0:
225
+ raise RuntimeError(f"Railpack build failed: {result.stderr}")
226
+
227
+ return result.stdout
228
+
229
+ except subprocess.TimeoutExpired:
230
+ raise RuntimeError("Railpack build timed out")
231
+ except Exception as e:
232
+ raise RuntimeError(f"Railpack build failed: {e}")
233
+
234
+ def _detect_framework(self, metadata: dict, project_path: Path) -> str:
235
+ """Detect framework from Railpack metadata and project files."""
236
+ # Check metadata hints
237
+ hints = metadata.get("hints", [])
238
+
239
+ for hint in hints:
240
+ hint_lower = hint.lower()
241
+ if "fastapi" in hint_lower:
242
+ return "fastapi"
243
+ elif "flask" in hint_lower:
244
+ return "flask"
245
+ elif "django" in hint_lower:
246
+ return "django"
247
+ elif "next" in hint_lower:
248
+ return "nextjs"
249
+ elif "express" in hint_lower:
250
+ return "express"
251
+ elif "nest" in hint_lower:
252
+ return "nestjs"
253
+
254
+ # Check for specific files
255
+ if (project_path / "next.config.js").exists() or (project_path / "next.config.ts").exists():
256
+ return "nextjs"
257
+
258
+ # Default to language
259
+ lang = metadata.get("language", "unknown")
260
+ return lang if lang != "unknown" else "generic"
261
+
262
+ def _get_dependency_file(self, package_manager: str, project_path: Path) -> str:
263
+ """Get the dependency file name based on package manager."""
264
+ mapping = {
265
+ "pip": "requirements.txt",
266
+ "poetry": "pyproject.toml",
267
+ "pipenv": "Pipfile",
268
+ "uv": "pyproject.toml",
269
+ "npm": "package.json",
270
+ "yarn": "package.json",
271
+ "pnpm": "package.json",
272
+ "bun": "package.json",
273
+ "go": "go.mod",
274
+ "cargo": "Cargo.toml",
275
+ "bundler": "Gemfile",
276
+ "composer": "composer.json",
277
+ }
278
+ return mapping.get(package_manager, "requirements.txt")
279
+
280
+ def _detect_port(self, metadata: dict, framework: str) -> int:
281
+ """Detect the port based on framework and metadata."""
282
+ # Check if port is specified in environment
283
+ for env in metadata.get("env", []):
284
+ if "PORT" in env:
285
+ try:
286
+ return int(env.split("=")[1])
287
+ except (IndexError, ValueError):
288
+ pass
289
+
290
+ # Framework defaults
291
+ framework_ports = {
292
+ "fastapi": 8000,
293
+ "flask": 5000,
294
+ "django": 8000,
295
+ "nextjs": 3000,
296
+ "express": 3000,
297
+ "nestjs": 3000,
298
+ }
299
+
300
+ return framework_ports.get(framework, 8000)
301
+
302
+ def _get_start_command(self, analysis: dict, framework: str, port: int) -> Optional[str]:
303
+ """Get the start command from Railpack analysis or infer it."""
304
+ # Check if Railpack provided a start command
305
+ commands = analysis.get("commands", {})
306
+ if "start" in commands:
307
+ return commands["start"]
308
+
309
+ # Infer based on framework
310
+ cmds = {
311
+ "fastapi": f"uvicorn main:app --host 0.0.0.0 --port {port}",
312
+ "flask": f"flask run --host=0.0.0.0 --port={port}",
313
+ "django": f"python manage.py runserver 0.0.0.0:{port}",
314
+ "nextjs": "npm start",
315
+ "express": "node server.js",
316
+ "nestjs": "npm run start:prod",
317
+ }
318
+
319
+ return cmds.get(framework)