xenfra-sdk 0.2.4__py3-none-any.whl → 0.2.6__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 (43) hide show
  1. xenfra_sdk/__init__.py +46 -2
  2. xenfra_sdk/blueprints/base.py +150 -0
  3. xenfra_sdk/blueprints/factory.py +99 -0
  4. xenfra_sdk/blueprints/node.py +219 -0
  5. xenfra_sdk/blueprints/python.py +57 -0
  6. xenfra_sdk/blueprints/railpack.py +99 -0
  7. xenfra_sdk/blueprints/schema.py +70 -0
  8. xenfra_sdk/cli/main.py +175 -49
  9. xenfra_sdk/client.py +6 -2
  10. xenfra_sdk/constants.py +26 -0
  11. xenfra_sdk/db/models.py +2 -0
  12. xenfra_sdk/db/session.py +8 -3
  13. xenfra_sdk/detection.py +262 -191
  14. xenfra_sdk/dockerizer.py +76 -120
  15. xenfra_sdk/engine.py +762 -160
  16. xenfra_sdk/events.py +254 -0
  17. xenfra_sdk/exceptions.py +9 -0
  18. xenfra_sdk/governance.py +150 -0
  19. xenfra_sdk/manifest.py +93 -138
  20. xenfra_sdk/mcp_client.py +7 -5
  21. xenfra_sdk/{models.py → models/__init__.py} +17 -1
  22. xenfra_sdk/models/context.py +61 -0
  23. xenfra_sdk/orchestrator.py +223 -99
  24. xenfra_sdk/privacy.py +11 -0
  25. xenfra_sdk/protocol.py +38 -0
  26. xenfra_sdk/railpack_adapter.py +357 -0
  27. xenfra_sdk/railpack_detector.py +587 -0
  28. xenfra_sdk/railpack_manager.py +312 -0
  29. xenfra_sdk/recipes.py +152 -19
  30. xenfra_sdk/resources/activity.py +45 -0
  31. xenfra_sdk/resources/build.py +157 -0
  32. xenfra_sdk/resources/deployments.py +22 -2
  33. xenfra_sdk/resources/intelligence.py +25 -0
  34. xenfra_sdk-0.2.6.dist-info/METADATA +118 -0
  35. xenfra_sdk-0.2.6.dist-info/RECORD +49 -0
  36. {xenfra_sdk-0.2.4.dist-info → xenfra_sdk-0.2.6.dist-info}/WHEEL +1 -1
  37. xenfra_sdk/templates/Caddyfile.j2 +0 -14
  38. xenfra_sdk/templates/Dockerfile.j2 +0 -41
  39. xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
  40. xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
  41. xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
  42. xenfra_sdk-0.2.4.dist-info/METADATA +0 -116
  43. xenfra_sdk-0.2.4.dist-info/RECORD +0 -38
@@ -0,0 +1,587 @@
1
+ """
2
+ Railpack Detector Module
3
+
4
+ Provides intelligent framework detection using Railway's Railpack buildpack.
5
+ Railpack analyzes project structure and dependencies to detect language,
6
+ framework, and build configuration.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any, List
16
+ from dataclasses import dataclass
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ @dataclass
22
+ class RailpackDetectionResult:
23
+ """Result from Railpack framework detection."""
24
+ framework: str
25
+ confidence: str # "high", "medium", "low"
26
+ detected_from: str
27
+ package_manager: Optional[str] = None
28
+ runtime_version: Optional[str] = None
29
+ start_command: Optional[str] = None
30
+ detected_port: Optional[int] = None
31
+ build_commands: List[str] = None
32
+ runtime_name: Optional[str] = None
33
+
34
+ def __post_init__(self):
35
+ if self.build_commands is None:
36
+ self.build_commands = []
37
+
38
+ @dataclass
39
+ class EnvVariable:
40
+ """Environment variable representation."""
41
+ id: str
42
+ key: str
43
+ value: str
44
+ is_secret: bool = False
45
+
46
+ class RailpackDetector:
47
+ """
48
+ Detects project framework and configuration using Railpack.
49
+
50
+ Railpack is Railway's open-source buildpack that auto-detects
51
+ languages and generates optimized container configurations.
52
+ """
53
+
54
+
55
+ def __init__(self, version: Optional[str] = None):
56
+ """
57
+ Initialize Railpack detector.
58
+
59
+ Args:
60
+ version: Specific Railpack version.
61
+ """
62
+ from xenfra_sdk.railpack_manager import get_railpack_manager
63
+ self.manager = get_railpack_manager(version)
64
+
65
+ def detect_from_path(self, project_path: str) -> RailpackDetectionResult:
66
+ """
67
+ Detect framework from a local project path.
68
+
69
+ Args:
70
+ project_path: Path to project directory.
71
+
72
+ Returns:
73
+ RailpackDetectionResult with detected framework info.
74
+
75
+ Raises:
76
+ RuntimeError: If Railpack fails to run.
77
+ """
78
+ # Ensure installed via manager
79
+ binary = self.manager.ensure_installed()
80
+
81
+ # Create temp file for plan output
82
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
83
+ plan_path = f.name
84
+
85
+ try:
86
+ # Run railpack prepare with absolute path to be safe
87
+ proc_result = subprocess.run(
88
+ [binary, "prepare", project_path, "--plan-out", plan_path],
89
+ cwd=project_path,
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=60
93
+ )
94
+
95
+ if proc_result.returncode != 0:
96
+ logger.error(f"Railpack prepare failed: {proc_result.stderr}")
97
+ return self._fallback_detection(project_path)
98
+
99
+ # Parse the plan
100
+ with open(plan_path) as f:
101
+ plan_content = f.read()
102
+ plan = json.loads(plan_content)
103
+
104
+ result = self._parse_plan(plan)
105
+
106
+ # If Railpack returns unknown, try our static fallback
107
+ if result.framework == "unknown":
108
+ logger.warning(
109
+ f"Railpack returned 'unknown' for {project_path}.\n"
110
+ f"Plan content: {plan_content[:1000]}...\n"
111
+ f"Stdout: {proc_result.stdout}\n"
112
+ f"Stderr: {proc_result.stderr}"
113
+ )
114
+ logger.info("Triggering static fallback...")
115
+ return self._fallback_detection(project_path)
116
+
117
+ return result
118
+
119
+ except subprocess.TimeoutExpired:
120
+ logger.warning("Railpack prepare timed out")
121
+ return self._fallback_detection(project_path)
122
+ except json.JSONDecodeError as e:
123
+ logger.warning(f"Failed to parse Railpack plan: {e}")
124
+ return self._fallback_detection(project_path)
125
+ except Exception as e:
126
+ logger.warning(f"Railpack detection error: {e}")
127
+ return self._fallback_detection(project_path)
128
+ finally:
129
+ if os.path.exists(plan_path):
130
+ os.unlink(plan_path)
131
+
132
+ def detect_from_manifest(self, file_manifest: List[Dict[str, Any]]) -> RailpackDetectionResult:
133
+ """
134
+ Quick detection from file manifest (without full Railpack run).
135
+
136
+ Uses static file detection but returns Railpack-compatible result format.
137
+
138
+ Args:
139
+ file_manifest: List of file info dicts with 'path' key.
140
+
141
+ Returns:
142
+ RailpackDetectionResult with detected framework info.
143
+ """
144
+ file_names = {f.get("path", "").lstrip("./") for f in file_manifest}
145
+
146
+ # === Node.js Detection ===
147
+ if "package.json" in file_names:
148
+ framework = "nodejs"
149
+ package_manager = "npm"
150
+ start_command = "npm start"
151
+ build_commands = []
152
+
153
+ if "pnpm-lock.yaml" in file_names:
154
+ package_manager = "pnpm"
155
+ elif "yarn.lock" in file_names:
156
+ package_manager = "yarn"
157
+ elif "bun.lockb" in file_names:
158
+ package_manager = "bun"
159
+
160
+ # Check for specific frameworks in content if available
161
+ for f in file_manifest:
162
+ if f.get("path") == "package.json" and f.get("content"):
163
+ content = f["content"].lower()
164
+ if "next" in content:
165
+ framework = "nextjs"
166
+ start_command = "npm run start" if package_manager == "npm" else f"{package_manager} start"
167
+ build_commands = ["npm run build" if package_manager == "npm" else f"{package_manager} build"]
168
+ elif "express" in content:
169
+ framework = "express"
170
+ start_command = "node server.js" # Common default
171
+ elif "nestjs" in content or "@nestjs/core" in content:
172
+ framework = "nestjs"
173
+ start_command = "npm run start:prod"
174
+ build_commands = ["npm run build"]
175
+ break
176
+
177
+ return RailpackDetectionResult(
178
+ framework=framework,
179
+ confidence="high",
180
+ detected_from="package.json",
181
+ package_manager=package_manager,
182
+ runtime_name="node",
183
+ start_command=start_command,
184
+ build_commands=build_commands
185
+ )
186
+
187
+ # === Python Detection ===
188
+ if "requirements.txt" in file_names or "pyproject.toml" in file_names:
189
+ framework = "python"
190
+ package_manager = "pip"
191
+ start_command = "python main.py"
192
+ detected_from = "requirements.txt" if "requirements.txt" in file_names else "pyproject.toml"
193
+
194
+ if "pyproject.toml" in file_names and "uv.lock" in file_names:
195
+ package_manager = "uv"
196
+ elif "Pipfile" in file_names:
197
+ package_manager = "pipenv"
198
+ elif "poetry.lock" in file_names:
199
+ package_manager = "poetry"
200
+
201
+ # Check for specific frameworks
202
+ for f in file_manifest:
203
+ path = f.get("path", "")
204
+ if path in ("requirements.txt", "pyproject.toml") and f.get("content"):
205
+ content = f["content"].lower()
206
+ if "fastapi" in content:
207
+ framework = "fastapi"
208
+ start_command = "uvicorn main:app --host 0.0.0.0 --port 8000"
209
+ elif "django" in content:
210
+ framework = "django"
211
+ start_command = "python manage.py runserver 0.0.0.0:8000"
212
+ elif "flask" in content:
213
+ framework = "flask"
214
+ start_command = "python app.py"
215
+ break
216
+
217
+ return RailpackDetectionResult(
218
+ framework=framework,
219
+ confidence="high",
220
+ detected_from=detected_from,
221
+ package_manager=package_manager,
222
+ runtime_name="python",
223
+ start_command=start_command
224
+ )
225
+
226
+ # === Go Detection ===
227
+ if "go.mod" in file_names:
228
+ framework = "go"
229
+
230
+ # Check for specific frameworks
231
+ for f in file_manifest:
232
+ if f.get("path") == "go.mod" and f.get("content"):
233
+ content = f["content"].lower()
234
+ if "gin-gonic" in content:
235
+ framework = "gin"
236
+ elif "labstack/echo" in content:
237
+ framework = "echo"
238
+ break
239
+
240
+ return RailpackDetectionResult(
241
+ framework=framework,
242
+ confidence="high",
243
+ detected_from="go.mod",
244
+ package_manager="go",
245
+ runtime_name="go"
246
+ )
247
+
248
+ # === Rust Detection ===
249
+ if "Cargo.toml" in file_names:
250
+ return RailpackDetectionResult(
251
+ framework="rust",
252
+ confidence="high",
253
+ detected_from="Cargo.toml",
254
+ package_manager="cargo",
255
+ runtime_name="rust"
256
+ )
257
+
258
+ # === Ruby Detection ===
259
+ if "Gemfile" in file_names:
260
+ framework = "ruby"
261
+ if "config.ru" in file_names:
262
+ framework = "rails"
263
+
264
+ return RailpackDetectionResult(
265
+ framework=framework,
266
+ confidence="high",
267
+ detected_from="Gemfile",
268
+ package_manager="bundler",
269
+ runtime_name="ruby"
270
+ )
271
+
272
+ # === PHP Detection ===
273
+ if "composer.json" in file_names:
274
+ return RailpackDetectionResult(
275
+ framework="php",
276
+ confidence="high",
277
+ detected_from="composer.json",
278
+ package_manager="composer",
279
+ runtime_name="php"
280
+ )
281
+
282
+ # === Java Detection ===
283
+ if "pom.xml" in file_names or "build.gradle" in file_names:
284
+ return RailpackDetectionResult(
285
+ framework="java",
286
+ confidence="high",
287
+ detected_from="pom.xml" if "pom.xml" in file_names else "build.gradle",
288
+ package_manager="maven" if "pom.xml" in file_names else "gradle",
289
+ runtime_name="java"
290
+ )
291
+
292
+ # === Docker Detection ===
293
+ if "Dockerfile" in file_names:
294
+ return RailpackDetectionResult(
295
+ framework="docker",
296
+ confidence="medium",
297
+ detected_from="Dockerfile",
298
+ runtime_name="docker"
299
+ )
300
+
301
+ # === Static Site ===
302
+ if "index.html" in file_names:
303
+ return RailpackDetectionResult(
304
+ framework="static",
305
+ confidence="medium",
306
+ detected_from="index.html",
307
+ runtime_name="static"
308
+ )
309
+
310
+ # Default fallback
311
+ return RailpackDetectionResult(
312
+ framework="unknown",
313
+ confidence="low",
314
+ detected_from="default",
315
+ runtime_name="unknown"
316
+ )
317
+
318
+ def _parse_plan(self, plan: Dict[str, Any]) -> RailpackDetectionResult:
319
+ """Parse Railpack plan JSON into detection result."""
320
+ runtime = plan.get("runtime", {})
321
+ phases = plan.get("phases", {})
322
+ caches = plan.get("caches", {})
323
+
324
+ runtime_name = runtime.get("name")
325
+
326
+ # Fuzzy detection for newer Railpack (0.17.x) where runtime field might be different
327
+ if not runtime_name or runtime_name == "unknown":
328
+ if any(k in caches for k in ("next", "node-modules", "npm-install", "yarn", "pnpm")):
329
+ runtime_name = "node"
330
+ elif any(k in caches for k in ("python", "pip", "uv", "poetry", "pipenv")):
331
+ runtime_name = "python"
332
+ elif "go-build" in caches:
333
+ runtime_name = "go"
334
+ elif any(k in caches for k in ("cargo", "cargo-cache", "rust")):
335
+ runtime_name = "rust"
336
+ elif "bundle" in caches or "ruby" in caches:
337
+ runtime_name = "ruby"
338
+ elif "composer" in caches:
339
+ runtime_name = "php"
340
+
341
+ # Fallback: Check deploy variables if cache scan failed
342
+ if runtime_name == "unknown":
343
+ variables = str(plan.get("deploy", {}).get("variables", {})).lower()
344
+ if "python" in variables:
345
+ runtime_name = "python"
346
+ elif "node" in variables or "npm" in variables:
347
+ runtime_name = "node"
348
+ elif "cargo" in variables or "rust" in variables:
349
+ runtime_name = "rust"
350
+ elif "go" in variables:
351
+ runtime_name = "go"
352
+
353
+ runtime_version = runtime.get("version")
354
+
355
+ # Get start command
356
+ start_phase = phases.get("start", {})
357
+ start_cmd = start_phase.get("cmd", "")
358
+
359
+ # Extract port from start command
360
+ detected_port = self._extract_port(start_cmd)
361
+
362
+ # Get build commands
363
+ build_commands = []
364
+ for phase_name in ["setup", "install", "build"]:
365
+ phase = phases.get(phase_name, {})
366
+ for cmd in phase.get("cmds", []):
367
+ build_commands.append(cmd)
368
+
369
+ # Map runtime to framework
370
+ framework = self._map_runtime_to_framework(runtime_name, plan)
371
+
372
+ # Detect package manager from packages
373
+ packages = plan.get("packages", {})
374
+ package_manager = self._detect_package_manager(runtime_name, packages)
375
+
376
+ return RailpackDetectionResult(
377
+ framework=framework,
378
+ confidence="high",
379
+ detected_from="railpack",
380
+ package_manager=package_manager,
381
+ runtime_version=runtime_version,
382
+ start_command=start_cmd,
383
+ detected_port=detected_port,
384
+ build_commands=build_commands,
385
+ runtime_name=runtime_name
386
+ )
387
+
388
+ def _extract_port(self, cmd: str) -> Optional[int]:
389
+ """Extract port from start command."""
390
+ import re
391
+
392
+ patterns = [
393
+ r'--port[=\s]*(\d+)',
394
+ r'-p[=\s]*(\d+)',
395
+ r'PORT[=\s]*(\d+)',
396
+ r':(\d+)', # URL-style :port
397
+ r'\s(\d{2,4})\s', # Standalone port number
398
+ ]
399
+
400
+ for pattern in patterns:
401
+ match = re.search(pattern, cmd)
402
+ if match:
403
+ port = int(match.group(1))
404
+ if 1000 <= port <= 65535:
405
+ return port
406
+
407
+ return None
408
+
409
+ def _map_runtime_to_framework(self, runtime_name: str, plan: Dict) -> str:
410
+ """Map Railpack runtime name to Xenfra framework name."""
411
+ runtime_mapping = {
412
+ "python": "python",
413
+ "node": "nodejs",
414
+ "go": "go",
415
+ "rust": "rust",
416
+ "ruby": "ruby",
417
+ "php": "php",
418
+ "java": "java",
419
+ "deno": "deno",
420
+ "bun": "bun",
421
+ }
422
+
423
+ base_framework = runtime_mapping.get(runtime_name, runtime_name)
424
+
425
+ # Try to detect specific framework from packages, commands, or caches
426
+ packages = str(plan.get("packages", {})).lower()
427
+ cmds = str(plan.get("phases", {})).lower()
428
+ caches = str(plan.get("caches", {})).lower()
429
+
430
+ if runtime_name == "python":
431
+ if "fastapi" in packages or "fastapi" in cmds or "fastapi" in caches:
432
+ return "fastapi"
433
+ elif "django" in packages or "django" in cmds or "django" in caches:
434
+ return "django"
435
+ elif "flask" in packages or "flask" in cmds or "flask" in caches:
436
+ return "flask"
437
+
438
+ elif runtime_name == "node":
439
+ if "next" in packages or "next" in cmds or "next" in caches:
440
+ return "nextjs"
441
+ elif "express" in packages or "express" in cmds or "express" in caches:
442
+ return "express"
443
+ elif "nestjs" in packages or "nest" in cmds or "nest" in caches:
444
+ return "nestjs"
445
+
446
+ elif runtime_name == "go":
447
+ if "gin" in packages or "gin-gonic" in cmds or "gin" in caches:
448
+ return "gin"
449
+ elif "echo" in packages or "labstack" in cmds or "echo" in caches:
450
+ return "echo"
451
+
452
+ elif runtime_name == "ruby":
453
+ if "rails" in packages or "rails" in caches:
454
+ return "rails"
455
+
456
+ return base_framework
457
+
458
+ def _detect_package_manager(self, runtime_name: str, packages: Dict) -> Optional[str]:
459
+ """Detect package manager from Railpack packages."""
460
+ pkg_str = str(packages).lower()
461
+
462
+ if runtime_name == "node":
463
+ if "pnpm" in pkg_str:
464
+ return "pnpm"
465
+ elif "yarn" in pkg_str:
466
+ return "yarn"
467
+ elif "bun" in pkg_str:
468
+ return "bun"
469
+ return "npm"
470
+
471
+ elif runtime_name == "python":
472
+ if "uv" in pkg_str:
473
+ return "uv"
474
+ elif "poetry" in pkg_str:
475
+ return "poetry"
476
+ elif "pipenv" in pkg_str:
477
+ return "pipenv"
478
+ return "pip"
479
+
480
+ elif runtime_name == "go":
481
+ return "go"
482
+
483
+ elif runtime_name == "rust":
484
+ return "cargo"
485
+
486
+ elif runtime_name == "ruby":
487
+ return "bundler"
488
+
489
+ elif runtime_name == "php":
490
+ return "composer"
491
+
492
+ elif runtime_name == "java":
493
+ if "gradle" in pkg_str:
494
+ return "gradle"
495
+ return "maven"
496
+
497
+ return None
498
+
499
+ def parse_env_content(self, content: str) -> List[EnvVariable]:
500
+ """
501
+ Parse .env file content into EnvVariable objects.
502
+
503
+ Args:
504
+ content: The content of a .env file
505
+
506
+ Returns:
507
+ List of EnvVariable objects
508
+ """
509
+ lines = content.split('\n')
510
+ variables = []
511
+
512
+ for line in lines:
513
+ trimmed = line.strip()
514
+
515
+ # Skip empty lines and comments
516
+ if not trimmed or trimmed.startswith("#"):
517
+ continue
518
+
519
+ # Use partition for robust handling of '=' in values
520
+ key, sep, value = trimmed.partition('=')
521
+
522
+ if sep:
523
+ key = key.strip()
524
+ value = value.strip()
525
+
526
+ # Validate key
527
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
528
+ continue
529
+
530
+ # Remove surrounding quotes if present
531
+ if ((value.startswith('"') and value.endswith('"')) or
532
+ (value.startswith("'") and value.endswith("'"))):
533
+ value = value[1:-1]
534
+
535
+ # Unescape escaped quotes
536
+ value = value.replace('\\"', '"').replace("\\'", "'")
537
+
538
+ # Detect if it looks like a secret
539
+ is_secret = bool(re.search(r'secret|key|token|password|private|api.?key|auth', key, re.IGNORECASE))
540
+
541
+ variables.append(EnvVariable(
542
+ id=f"env_{len(variables)}",
543
+ key=key,
544
+ value=value,
545
+ is_secret=is_secret
546
+ ))
547
+
548
+ return variables
549
+
550
+ def _fallback_detection(self, project_path: str) -> RailpackDetectionResult:
551
+ """Fallback to static file detection if Railpack fails."""
552
+ # Build file manifest from directory
553
+ file_manifest = []
554
+ for root, dirs, files in os.walk(project_path):
555
+ # Skip common non-project directories
556
+ dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', '__pycache__', 'target', 'vendor')]
557
+ for file in files:
558
+ file_path = os.path.join(root, file)
559
+ rel_path = os.path.relpath(file_path, project_path)
560
+ file_manifest.append({"path": rel_path})
561
+
562
+ return self.detect_from_manifest(file_manifest)
563
+
564
+
565
+ # Global detector instance
566
+ _railpack_detector: Optional[RailpackDetector] = None
567
+
568
+
569
+ def get_railpack_detector(version: Optional[str] = None) -> RailpackDetector:
570
+ """
571
+ Get or create global RailpackDetector instance.
572
+
573
+ Args:
574
+ version: Specific version to use. If None, uses default.
575
+
576
+ Returns:
577
+ RailpackDetector singleton
578
+ """
579
+ global _railpack_detector
580
+
581
+ if _railpack_detector is None:
582
+ _railpack_detector = RailpackDetector(version=version)
583
+ elif version is not None and _railpack_detector.version != version:
584
+ # Requested different version, create new instance
585
+ _railpack_detector = RailpackDetector(version=version)
586
+
587
+ return _railpack_detector