xenfra-sdk 0.2.2__py3-none-any.whl → 0.2.3__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.
- xenfra_sdk/__init__.py +61 -21
- xenfra_sdk/cli/main.py +226 -226
- xenfra_sdk/client.py +90 -90
- xenfra_sdk/config.py +26 -26
- xenfra_sdk/db/models.py +24 -24
- xenfra_sdk/db/session.py +30 -30
- xenfra_sdk/dependencies.py +39 -39
- xenfra_sdk/detection.py +396 -0
- xenfra_sdk/dockerizer.py +195 -194
- xenfra_sdk/engine.py +741 -619
- xenfra_sdk/exceptions.py +19 -19
- xenfra_sdk/manifest.py +212 -0
- xenfra_sdk/mcp_client.py +154 -154
- xenfra_sdk/models.py +184 -184
- xenfra_sdk/orchestrator.py +666 -0
- xenfra_sdk/patterns.json +13 -13
- xenfra_sdk/privacy.py +153 -153
- xenfra_sdk/recipes.py +26 -26
- xenfra_sdk/resources/base.py +3 -3
- xenfra_sdk/resources/deployments.py +278 -248
- xenfra_sdk/resources/files.py +101 -101
- xenfra_sdk/resources/intelligence.py +102 -95
- xenfra_sdk/security.py +41 -41
- xenfra_sdk/security_scanner.py +431 -0
- xenfra_sdk/templates/Caddyfile.j2 +14 -0
- xenfra_sdk/templates/Dockerfile.j2 +41 -38
- xenfra_sdk/templates/cloud-init.sh.j2 +90 -90
- xenfra_sdk/templates/docker-compose-multi.yml.j2 +29 -0
- xenfra_sdk/templates/docker-compose.yml.j2 +30 -30
- xenfra_sdk-0.2.3.dist-info/METADATA +116 -0
- xenfra_sdk-0.2.3.dist-info/RECORD +38 -0
- xenfra_sdk-0.2.2.dist-info/METADATA +0 -118
- xenfra_sdk-0.2.2.dist-info/RECORD +0 -32
- {xenfra_sdk-0.2.2.dist-info → xenfra_sdk-0.2.3.dist-info}/WHEEL +0 -0
xenfra_sdk/detection.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DetectionResult:
|
|
11
|
+
"""Standardized result for service detection."""
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
name: str,
|
|
15
|
+
path: str,
|
|
16
|
+
tier: str, # "backend", "frontend", "agent"
|
|
17
|
+
framework: str,
|
|
18
|
+
entrypoint: Optional[str] = None,
|
|
19
|
+
port: int = 8000,
|
|
20
|
+
package_manager: str = "pip",
|
|
21
|
+
dependency_file: str = "requirements.txt",
|
|
22
|
+
missing_deps: List[str] = None
|
|
23
|
+
):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.path = path
|
|
26
|
+
self.tier = tier
|
|
27
|
+
self.framework = framework
|
|
28
|
+
self.entrypoint = entrypoint
|
|
29
|
+
self.port = port
|
|
30
|
+
self.package_manager = package_manager
|
|
31
|
+
self.dependency_file = dependency_file
|
|
32
|
+
self.missing_deps = missing_deps or []
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"path": self.path,
|
|
38
|
+
"tier": self.tier,
|
|
39
|
+
"framework": self.framework,
|
|
40
|
+
"entrypoint": self.entrypoint,
|
|
41
|
+
"port": self.port,
|
|
42
|
+
"package_manager": self.package_manager,
|
|
43
|
+
"dependency_file": self.dependency_file,
|
|
44
|
+
"missing_deps": self.missing_deps,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BaseProbe(ABC):
|
|
49
|
+
"""Base class for language-specific detection probes."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PythonProbe(BaseProbe):
|
|
57
|
+
"""Probe for Python-based backends and agents."""
|
|
58
|
+
|
|
59
|
+
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
60
|
+
has_pyproject = (service_dir / "pyproject.toml").exists()
|
|
61
|
+
has_requirements = (service_dir / "requirements.txt").exists()
|
|
62
|
+
|
|
63
|
+
if not has_pyproject and not has_requirements:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
content = ""
|
|
67
|
+
if has_pyproject:
|
|
68
|
+
content = (service_dir / "pyproject.toml").read_text(errors="ignore").lower()
|
|
69
|
+
elif has_requirements:
|
|
70
|
+
content = (service_dir / "requirements.txt").read_text(errors="ignore").lower()
|
|
71
|
+
|
|
72
|
+
# Determine Tier & Framework
|
|
73
|
+
tier = "backend"
|
|
74
|
+
framework = "fastapi" # Default
|
|
75
|
+
|
|
76
|
+
# Check for Agents
|
|
77
|
+
if any(lib in content for lib in ["mcp", "langgraph", "crewai", "langchain"]):
|
|
78
|
+
tier = "agent"
|
|
79
|
+
framework = "mcp" if "mcp" in content else "langgraph"
|
|
80
|
+
elif "django" in content or "djangorestframework" in content:
|
|
81
|
+
framework = "django"
|
|
82
|
+
elif "flask" in content:
|
|
83
|
+
framework = "flask"
|
|
84
|
+
|
|
85
|
+
entrypoint = _detect_python_entrypoint(service_dir, name)
|
|
86
|
+
|
|
87
|
+
# Proactive Dependency Scanning (Zen Mode Auto-Healing)
|
|
88
|
+
missing_deps = _scan_proactive_dependencies(service_dir)
|
|
89
|
+
|
|
90
|
+
return DetectionResult(
|
|
91
|
+
name=name,
|
|
92
|
+
path=str(service_dir),
|
|
93
|
+
tier=tier,
|
|
94
|
+
framework=framework,
|
|
95
|
+
entrypoint=entrypoint,
|
|
96
|
+
package_manager="uv" if has_pyproject else "pip",
|
|
97
|
+
dependency_file="pyproject.toml" if has_pyproject else "requirements.txt",
|
|
98
|
+
missing_deps=missing_deps
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NodeProbe(BaseProbe):
|
|
103
|
+
"""Probe for Node.js based frontends and backends."""
|
|
104
|
+
|
|
105
|
+
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
106
|
+
package_json = service_dir / "package.json"
|
|
107
|
+
if not package_json.exists():
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with open(package_json, "r") as f:
|
|
112
|
+
config = json.load(f)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
deps = {**config.get("dependencies", {}), **config.get("devDependencies", {})}
|
|
117
|
+
|
|
118
|
+
# Determine Tier
|
|
119
|
+
tier = "backend"
|
|
120
|
+
framework = "node"
|
|
121
|
+
|
|
122
|
+
if any(lib in deps for lib in ["next", "react", "vue", "svelte", "nuxt", "angular"]):
|
|
123
|
+
tier = "frontend"
|
|
124
|
+
framework = "nextjs" if "next" in deps else ("react" if "react" in deps else "frontend")
|
|
125
|
+
elif any(lib in deps for lib in ["express", "fastify", "nest", "hono"]):
|
|
126
|
+
tier = "backend"
|
|
127
|
+
framework = "express" if "express" in deps else "node-backend"
|
|
128
|
+
elif "@modelcontextprotocol/sdk" in deps:
|
|
129
|
+
tier = "agent"
|
|
130
|
+
framework = "mcp"
|
|
131
|
+
|
|
132
|
+
return DetectionResult(
|
|
133
|
+
name=name,
|
|
134
|
+
path=str(service_dir),
|
|
135
|
+
tier=tier,
|
|
136
|
+
framework=framework,
|
|
137
|
+
package_manager="npm",
|
|
138
|
+
dependency_file="package.json",
|
|
139
|
+
port=3000 if tier == "frontend" else 8000
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class GoProbe(BaseProbe):
|
|
144
|
+
"""Probe for Go backends."""
|
|
145
|
+
|
|
146
|
+
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
147
|
+
go_mod = service_dir / "go.mod"
|
|
148
|
+
if not go_mod.exists():
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
content = go_mod.read_text(errors="ignore").lower()
|
|
152
|
+
framework = "go-std"
|
|
153
|
+
if "gin-gonic" in content:
|
|
154
|
+
framework = "gin"
|
|
155
|
+
elif "labstack/echo" in content:
|
|
156
|
+
framework = "echo"
|
|
157
|
+
|
|
158
|
+
return DetectionResult(
|
|
159
|
+
name=name,
|
|
160
|
+
path=str(service_dir),
|
|
161
|
+
tier="backend",
|
|
162
|
+
framework=framework,
|
|
163
|
+
package_manager="go",
|
|
164
|
+
dependency_file="go.mod"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DetectionRegistry:
|
|
169
|
+
"""Registry of probes for multi-language detection."""
|
|
170
|
+
|
|
171
|
+
def __init__(self):
|
|
172
|
+
self.probes: List[BaseProbe] = [
|
|
173
|
+
PythonProbe(),
|
|
174
|
+
NodeProbe(),
|
|
175
|
+
GoProbe()
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
def detect_all(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
179
|
+
for probe in self.probes:
|
|
180
|
+
result = probe.detect(service_dir, name)
|
|
181
|
+
if result:
|
|
182
|
+
return result
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- Singleton for discovery ---
|
|
187
|
+
_registry = DetectionRegistry()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def detect_docker_compose_services(project_path: str = ".") -> Optional[List[dict]]:
|
|
191
|
+
"""Parse docker-compose.yml to detect services."""
|
|
192
|
+
compose_path = Path(project_path) / "docker-compose.yml"
|
|
193
|
+
if not compose_path.exists():
|
|
194
|
+
compose_path = Path(project_path) / "docker-compose.yaml"
|
|
195
|
+
|
|
196
|
+
if not compose_path.exists():
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
with open(compose_path, "r", encoding="utf-8") as f:
|
|
201
|
+
compose = yaml.safe_load(f)
|
|
202
|
+
except yaml.YAMLError:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
if not compose or "services" not in compose:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
services = []
|
|
209
|
+
base_port = 8000
|
|
210
|
+
|
|
211
|
+
for name, config in compose.get("services", {}).items():
|
|
212
|
+
if _is_infrastructure_service(name, config):
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
build_config = config.get("build", ".")
|
|
216
|
+
build_path = build_config.get("context", ".") if isinstance(build_config, dict) else str(build_config)
|
|
217
|
+
|
|
218
|
+
src_path = (Path(project_path) / build_path).resolve()
|
|
219
|
+
|
|
220
|
+
# Use our new registry for deep detection
|
|
221
|
+
result = _registry.detect_all(src_path, name)
|
|
222
|
+
|
|
223
|
+
if result:
|
|
224
|
+
svc_dict = result.to_dict()
|
|
225
|
+
svc_dict["path"] = build_path # Use relative path from compose
|
|
226
|
+
# Override port if explicitly set in compose
|
|
227
|
+
svc_dict["port"] = _extract_port(config.get("ports", []), svc_dict["port"])
|
|
228
|
+
services.append(svc_dict)
|
|
229
|
+
else:
|
|
230
|
+
# Fallback for unrecognized languages
|
|
231
|
+
services.append({
|
|
232
|
+
"name": name,
|
|
233
|
+
"path": build_path,
|
|
234
|
+
"port": _extract_port(config.get("ports", []), base_port + len(services)),
|
|
235
|
+
"framework": "other",
|
|
236
|
+
"tier": "backend"
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return services if services else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def detect_pyproject_services(project_path: str = ".") -> Optional[List[dict]]:
|
|
243
|
+
"""Scan for multiple services/* directories with language indicators."""
|
|
244
|
+
services = []
|
|
245
|
+
project_root = Path(project_path).resolve()
|
|
246
|
+
|
|
247
|
+
services_dir = project_root / "services"
|
|
248
|
+
if not services_dir.exists():
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
for service_dir in sorted(services_dir.iterdir()):
|
|
252
|
+
if not service_dir.is_dir():
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
result = _registry.detect_all(service_dir, service_dir.name)
|
|
256
|
+
if result:
|
|
257
|
+
svc_dict = result.to_dict()
|
|
258
|
+
svc_dict["path"] = f"./services/{service_dir.name}"
|
|
259
|
+
services.append(svc_dict)
|
|
260
|
+
|
|
261
|
+
return services if len(services) > 1 else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def auto_detect_services(project_path: str = ".") -> Optional[List[dict]]:
|
|
265
|
+
"""Try all detection methods in priority order."""
|
|
266
|
+
services = detect_docker_compose_services(project_path)
|
|
267
|
+
if services:
|
|
268
|
+
return services
|
|
269
|
+
|
|
270
|
+
services = detect_pyproject_services(project_path)
|
|
271
|
+
if services:
|
|
272
|
+
return services
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- Helper Functions (Internal) ---
|
|
278
|
+
|
|
279
|
+
def _is_infrastructure_service(name: str, config: dict) -> bool:
|
|
280
|
+
infra_names = {"db", "database", "postgres", "mysql", "redis", "mongo", "mongodb",
|
|
281
|
+
"elasticsearch", "rabbitmq", "kafka", "zookeeper", "memcached", "nginx"}
|
|
282
|
+
if name.lower() in infra_names: return True
|
|
283
|
+
image = config.get("image", "")
|
|
284
|
+
return any(infra in image.lower() for infra in infra_names)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_port(ports: list, default: int) -> int:
|
|
288
|
+
if not ports: return default
|
|
289
|
+
port_str = str(ports[0])
|
|
290
|
+
if isinstance(ports[0], dict): return int(ports[0].get("published", default))
|
|
291
|
+
match = re.match(r"(\d+):", port_str)
|
|
292
|
+
return int(match.group(1)) if match else (int(port_str) if port_str.isdigit() else default)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _detect_python_entrypoint(service_dir: Path, service_name: str = None) -> Optional[str]:
|
|
296
|
+
"""Internal helper for PythonProbe."""
|
|
297
|
+
candidates = [
|
|
298
|
+
("main.py", "main:app"),
|
|
299
|
+
("app.py", "app:app"),
|
|
300
|
+
("api.py", "api:app"),
|
|
301
|
+
("wsgi.py", "wsgi:application"),
|
|
302
|
+
("asgi.py", "asgi:application"),
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
for filename, entrypoint in candidates:
|
|
306
|
+
if (service_dir / filename).exists(): return entrypoint
|
|
307
|
+
|
|
308
|
+
for container_name in ["src", "app", "services"]:
|
|
309
|
+
container_dir = service_dir / container_name
|
|
310
|
+
if container_dir.exists() and container_dir.is_dir():
|
|
311
|
+
if service_name:
|
|
312
|
+
svc_subdir = container_dir / service_name
|
|
313
|
+
if svc_subdir.exists():
|
|
314
|
+
for f, s in candidates:
|
|
315
|
+
if (svc_subdir / f).exists(): return f"{container_name}.{service_name}.{s}"
|
|
316
|
+
|
|
317
|
+
for subdir in container_dir.iterdir():
|
|
318
|
+
if subdir.is_dir() and not subdir.name.startswith((".", "__")):
|
|
319
|
+
for f, s in candidates:
|
|
320
|
+
if (subdir / f).exists(): return f"{container_name}.{subdir.name}.{s}"
|
|
321
|
+
|
|
322
|
+
for subdir in service_dir.iterdir():
|
|
323
|
+
if subdir.is_dir() and not subdir.name.startswith((".", "__", "src", "app", "services", "venv", ".venv")):
|
|
324
|
+
for f, s in candidates:
|
|
325
|
+
if (subdir / f).exists(): return f"{subdir.name}.{s}"
|
|
326
|
+
|
|
327
|
+
return _discover_entrypoint_by_content(service_dir)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _scan_proactive_dependencies(service_dir: Path) -> List[str]:
|
|
331
|
+
"""
|
|
332
|
+
Scan source code for framework-specific requirements that are often missed.
|
|
333
|
+
Example: FastAPI Form/File/OAuth2 requirements.
|
|
334
|
+
"""
|
|
335
|
+
missing = []
|
|
336
|
+
patterns = [
|
|
337
|
+
(r"\b(?:Form|File|OAuth2PasswordRequestForm)\b", "python-multipart"),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
excludes = {".venv", "venv", "tests", "__pycache__", "node_modules", ".git"}
|
|
341
|
+
|
|
342
|
+
# Speed check: Read main files first
|
|
343
|
+
for py_file in service_dir.rglob("*.py"):
|
|
344
|
+
if any(ex in py_file.parts for ex in excludes):
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
content = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
349
|
+
for pattern, dep in patterns:
|
|
350
|
+
if dep not in missing and re.search(pattern, content):
|
|
351
|
+
# Check if it's already in the project's dependency files
|
|
352
|
+
if not _is_dep_already_present(service_dir, dep):
|
|
353
|
+
missing.append(dep)
|
|
354
|
+
except Exception:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
return missing
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _is_dep_already_present(service_dir: Path, dep: str) -> bool:
|
|
361
|
+
"""Check if a dependency is already listed in pyproject.toml or requirements.txt."""
|
|
362
|
+
for filename in ["pyproject.toml", "requirements.txt"]:
|
|
363
|
+
fpath = service_dir / filename
|
|
364
|
+
if fpath.exists():
|
|
365
|
+
try:
|
|
366
|
+
if dep.lower() in fpath.read_text().lower():
|
|
367
|
+
return True
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _discover_entrypoint_by_content(service_dir: Path) -> Optional[str]:
|
|
374
|
+
patterns = [
|
|
375
|
+
(r"(\w+)\s*=\s*(?:fastapi\.)?FastAPI\(", "{module}:{var}"),
|
|
376
|
+
(r"(\w+)\s*=\s*(?:flask\.)?Flask\(", "{module}:{var}"),
|
|
377
|
+
(r"application\s*=\s*get_wsgi_application\(", "{module}:application"),
|
|
378
|
+
(r"application\s*=\s*get_asgi_application\(", "{module}:application"),
|
|
379
|
+
]
|
|
380
|
+
excludes = {".venv", "venv", "tests", "__pycache__", "node_modules", ".git", ".xenfra"}
|
|
381
|
+
|
|
382
|
+
for py_file in service_dir.rglob("*.py"):
|
|
383
|
+
if any(ex in py_file.parts for ex in excludes): continue
|
|
384
|
+
try:
|
|
385
|
+
content = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
386
|
+
for pattern, template in patterns:
|
|
387
|
+
match = re.search(pattern, content)
|
|
388
|
+
if match:
|
|
389
|
+
try:
|
|
390
|
+
rel_path = py_file.relative_to(service_dir)
|
|
391
|
+
module_path = ".".join(rel_path.with_suffix("").parts)
|
|
392
|
+
var_name = match.group(1) if match.groups() else "application"
|
|
393
|
+
return template.format(module=module_path, var=var_name)
|
|
394
|
+
except ValueError: continue
|
|
395
|
+
except Exception: continue
|
|
396
|
+
return None
|