xenfra-sdk 0.2.1__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/dockerizer.py CHANGED
@@ -1,104 +1,195 @@
1
- import os
2
- from pathlib import Path
3
-
4
- from jinja2 import Environment, FileSystemLoader
5
-
6
-
7
- def detect_framework(path="."):
8
- """
9
- Scans common Python project structures to guess the framework and entrypoint.
10
- Returns: (framework_name, default_port, start_command) or (None, None, None)
11
- """
12
- project_root = Path(path).resolve()
13
-
14
- # Check for Django first (common pattern: manage.py in root)
15
- if (project_root / "manage.py").is_file():
16
- # Assume the project name is the current directory name
17
- project_name = project_root.name
18
- return "django", 8000, f"gunicorn {project_name}.wsgi:application --bind 0.0.0.0:8000"
19
-
20
- candidate_files = []
21
-
22
- # Check directly in project root
23
- for name in ["main.py", "app.py"]:
24
- if (project_root / name).is_file():
25
- candidate_files.append(project_root / name)
26
-
27
- # Check in src/*/ (standard package layout)
28
- for src_dir in project_root.glob("src/*"):
29
- if src_dir.is_dir():
30
- for name in ["main.py", "app.py"]:
31
- if (src_dir / name).is_file():
32
- candidate_files.append(src_dir / name)
33
-
34
- for file_path in candidate_files:
35
- with open(file_path, "r") as f:
36
- content = f.read()
37
-
38
- try:
39
- module_name = str(file_path.relative_to(project_root)).replace(os.sep, '.')[:-3]
40
- # If path is like src/testdeploy/main.py, module_name becomes src.testdeploy.main
41
- if module_name.startswith("src."):
42
- # Strip the "src." prefix for gunicorn/uvicorn
43
- module_name = module_name[4:]
44
- except ValueError:
45
- module_name = file_path.stem
46
-
47
- if "FastAPI" in content:
48
- # Use standard :app convention
49
- return "fastapi", 8000, f"uvicorn {module_name}:app --host 0.0.0.0 --port 8000"
50
-
51
- if "Flask" in content:
52
- return "flask", 5000, f"gunicorn {module_name}:app -b 0.0.0.0:5000"
53
-
54
- return None, None, None
55
-
56
-
57
- def generate_templated_assets(context: dict):
58
- """
59
- Generates deployment assets (Dockerfile, docker-compose.yml) using Jinja2 templates.
60
-
61
- Args:
62
- context: A dictionary containing information for rendering templates,
63
- e.g., {'database': 'postgres', 'python_version': 'python:3.11-slim'}
64
- """
65
- # Path to the templates directory
66
- template_dir = Path(__file__).parent / "templates"
67
- env = Environment(loader=FileSystemLoader(template_dir))
68
-
69
- # Detect framework specifics (use context if available, otherwise fallback to manual)
70
- detected_framework, detected_port, detected_command = detect_framework()
71
-
72
- framework = context.get("framework") or detected_framework
73
- port = context.get("port") or detected_port
74
- command = context.get("command") or detected_command
75
-
76
- if not framework:
77
- print("Warning: No recognizable web framework detected and no framework provided in context.")
78
- return []
79
-
80
- # Merge detected values with provided context (context takes precedence)
81
- render_context = {
82
- "framework": framework,
83
- "port": port,
84
- "command": command,
85
- **context
86
- }
87
-
88
- generated_files = []
89
-
90
- # --- 1. Dockerfile ---
91
- dockerfile_template = env.get_template("Dockerfile.j2")
92
- dockerfile_content = dockerfile_template.render(render_context)
93
- with open("Dockerfile", "w") as f:
94
- f.write(dockerfile_content)
95
- generated_files.append("Dockerfile")
96
-
97
- # --- 2. docker-compose.yml ---
98
- compose_template = env.get_template("docker-compose.yml.j2")
99
- compose_content = compose_template.render(render_context)
100
- with open("docker-compose.yml", "w") as f:
101
- f.write(compose_content)
102
- generated_files.append("docker-compose.yml")
103
-
104
- return generated_files
1
+ """
2
+ Xenfra Dockerizer - Generates deployment assets as strings.
3
+
4
+ This module renders Dockerfile and docker-compose.yml templates
5
+ to strings (in-memory) so they can be written to the target droplet via SSH.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Dict, Optional
10
+ import re
11
+
12
+ from jinja2 import Environment, FileSystemLoader
13
+
14
+
15
+ def detect_python_version(file_manifest: list = None) -> str:
16
+ """
17
+ Detect Python version from project files.
18
+
19
+ Checks in order:
20
+ 1. .python-version file (e.g., "3.13")
21
+ 2. pyproject.toml requires-python field (e.g., ">=3.13")
22
+
23
+ Args:
24
+ file_manifest: List of file info dicts with 'path' and optionally 'content'
25
+ If None, uses 3.11 as default.
26
+
27
+ Returns:
28
+ Docker image version string (e.g., "python:3.13-slim")
29
+ """
30
+ default_version = "python:3.11-slim"
31
+
32
+ if not file_manifest:
33
+ return default_version
34
+
35
+ # Build a lookup dict for quick access
36
+ file_lookup = {f.get('path', ''): f for f in file_manifest}
37
+
38
+ # Option 1: Check .python-version file
39
+ if '.python-version' in file_lookup:
40
+ file_info = file_lookup['.python-version']
41
+ content = file_info.get('content', '')
42
+ if content:
43
+ # Parse version like "3.13" or "3.13.1"
44
+ version = content.strip().split('\n')[0].strip()
45
+ if version:
46
+ # Extract major.minor (e.g., "3.13" from "3.13.1")
47
+ match = re.match(r'(\d+\.\d+)', version)
48
+ if match:
49
+ return f"python:{match.group(1)}-slim"
50
+
51
+ # Option 2: Check pyproject.toml requires-python
52
+ if 'pyproject.toml' in file_lookup:
53
+ file_info = file_lookup['pyproject.toml']
54
+ content = file_info.get('content', '')
55
+ if content:
56
+ # Parse requires-python = ">=3.13" or "^3.13"
57
+ match = re.search(r'requires-python\s*=\s*["\']([^"\']+)["\']', content)
58
+ if match:
59
+ version_spec = match.group(1)
60
+ # Extract version number (e.g., "3.13" from ">=3.13")
61
+ version_match = re.search(r'(\d+\.\d+)', version_spec)
62
+ if version_match:
63
+ return f"python:{version_match.group(1)}-slim"
64
+
65
+ return default_version
66
+
67
+
68
+ def render_deployment_assets(context: dict) -> Dict[str, str]:
69
+ """
70
+ Renders deployment assets (Dockerfile, docker-compose.yml) using Jinja2 templates.
71
+
72
+ IMPORTANT: This function returns strings, NOT files. The caller is responsible
73
+ for writing these to the correct location (e.g., via SSH to a remote droplet).
74
+
75
+ Args:
76
+ context: A dictionary containing information for rendering templates.
77
+ Required keys:
78
+ - framework: str (fastapi, flask, django)
79
+ - port: int (default 8000)
80
+ Optional keys:
81
+ - command: str (start command, auto-generated if not provided)
82
+ - database: str (postgres, mysql, etc.)
83
+ - package_manager: str (pip, uv)
84
+ - dependency_file: str (requirements.txt, pyproject.toml)
85
+ - python_version: str (default python:3.11-slim)
86
+
87
+ Returns:
88
+ Dict with keys "Dockerfile" and "docker-compose.yml", values are rendered content strings.
89
+ Returns empty dict if no framework is provided.
90
+ """
91
+ # Path to the templates directory
92
+ template_dir = Path(__file__).parent / "templates"
93
+ env = Environment(loader=FileSystemLoader(template_dir))
94
+
95
+ # Get framework from context (MUST be provided by caller, no auto-detection)
96
+ framework = context.get("framework")
97
+ if not framework:
98
+ # Framework is required - caller should have validated this
99
+ return {}
100
+
101
+ # Get port with default
102
+ port = context.get("port") or 8000
103
+
104
+ # Generate default command based on framework if not provided
105
+ command = context.get("command")
106
+ if not command:
107
+ if framework == "fastapi":
108
+ command = f"uvicorn main:app --host 0.0.0.0 --port {port}"
109
+ elif framework == "flask":
110
+ command = f"gunicorn app:app -b 0.0.0.0:{port}"
111
+ elif framework == "django":
112
+ command = f"gunicorn app.wsgi:application --bind 0.0.0.0:{port}"
113
+ else:
114
+ command = f"uvicorn main:app --host 0.0.0.0 --port {port}"
115
+
116
+ # Build render context with all values
117
+ render_context = {
118
+ "framework": framework,
119
+ "port": port,
120
+ "command": command,
121
+ "database": context.get("database"),
122
+ "package_manager": context.get("package_manager", "pip"),
123
+ "dependency_file": context.get("dependency_file", "requirements.txt"),
124
+ "python_version": context.get("python_version", "python:3.11-slim"),
125
+ "missing_deps": context.get("missing_deps", []),
126
+ # Pass through any additional context
127
+ **context,
128
+ }
129
+
130
+ result = {}
131
+
132
+ # --- 1. Render Dockerfile ---
133
+ dockerfile_template = env.get_template("Dockerfile.j2")
134
+ result["Dockerfile"] = dockerfile_template.render(render_context)
135
+
136
+ # --- 2. Render docker-compose.yml ---
137
+ compose_template = env.get_template("docker-compose.yml.j2")
138
+ result["docker-compose.yml"] = compose_template.render(render_context)
139
+
140
+ return result
141
+
142
+
143
+ # Keep detect_framework for potential local CLI use (not used in remote deployment)
144
+ def detect_framework(path: str = ".") -> tuple:
145
+ """
146
+ Scans common Python project structures to guess the framework and entrypoint.
147
+
148
+ NOTE: This is only useful when running LOCALLY on the user's machine.
149
+ It should NOT be called when the engine runs on a remote server.
150
+
151
+ Returns: (framework_name, default_port, start_command) or (None, None, None)
152
+ """
153
+ project_root = Path(path).resolve()
154
+
155
+ # Check for Django first (common pattern: manage.py in root)
156
+ if (project_root / "manage.py").is_file():
157
+ project_name = project_root.name
158
+ return "django", 8000, f"gunicorn {project_name}.wsgi:application --bind 0.0.0.0:8000"
159
+
160
+ candidate_files = []
161
+
162
+ # Check directly in project root
163
+ for name in ["main.py", "app.py"]:
164
+ if (project_root / name).is_file():
165
+ candidate_files.append(project_root / name)
166
+
167
+ # Check in src/*/ (standard package layout)
168
+ for src_dir in project_root.glob("src/*"):
169
+ if src_dir.is_dir():
170
+ for name in ["main.py", "app.py"]:
171
+ if (src_dir / name).is_file():
172
+ candidate_files.append(src_dir / name)
173
+
174
+ import os
175
+ for file_path in candidate_files:
176
+ try:
177
+ with open(file_path, "r") as f:
178
+ content = f.read()
179
+ except Exception:
180
+ continue
181
+
182
+ try:
183
+ module_name = str(file_path.relative_to(project_root)).replace(os.sep, '.')[:-3]
184
+ if module_name.startswith("src."):
185
+ module_name = module_name[4:]
186
+ except ValueError:
187
+ module_name = file_path.stem
188
+
189
+ if "FastAPI" in content:
190
+ return "fastapi", 8000, f"uvicorn {module_name}:app --host 0.0.0.0 --port 8000"
191
+
192
+ if "Flask" in content:
193
+ return "flask", 5000, f"gunicorn {module_name}:app -b 0.0.0.0:5000"
194
+
195
+ return None, None, None