xenfra-sdk 0.2.2__py3-none-any.whl → 0.2.4__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,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