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/dockerizer.py CHANGED
@@ -1,194 +1,195 @@
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
- # Pass through any additional context
126
- **context,
127
- }
128
-
129
- result = {}
130
-
131
- # --- 1. Render Dockerfile ---
132
- dockerfile_template = env.get_template("Dockerfile.j2")
133
- result["Dockerfile"] = dockerfile_template.render(render_context)
134
-
135
- # --- 2. Render docker-compose.yml ---
136
- compose_template = env.get_template("docker-compose.yml.j2")
137
- result["docker-compose.yml"] = compose_template.render(render_context)
138
-
139
- return result
140
-
141
-
142
- # Keep detect_framework for potential local CLI use (not used in remote deployment)
143
- def detect_framework(path: str = ".") -> tuple:
144
- """
145
- Scans common Python project structures to guess the framework and entrypoint.
146
-
147
- NOTE: This is only useful when running LOCALLY on the user's machine.
148
- It should NOT be called when the engine runs on a remote server.
149
-
150
- Returns: (framework_name, default_port, start_command) or (None, None, None)
151
- """
152
- project_root = Path(path).resolve()
153
-
154
- # Check for Django first (common pattern: manage.py in root)
155
- if (project_root / "manage.py").is_file():
156
- project_name = project_root.name
157
- return "django", 8000, f"gunicorn {project_name}.wsgi:application --bind 0.0.0.0:8000"
158
-
159
- candidate_files = []
160
-
161
- # Check directly in project root
162
- for name in ["main.py", "app.py"]:
163
- if (project_root / name).is_file():
164
- candidate_files.append(project_root / name)
165
-
166
- # Check in src/*/ (standard package layout)
167
- for src_dir in project_root.glob("src/*"):
168
- if src_dir.is_dir():
169
- for name in ["main.py", "app.py"]:
170
- if (src_dir / name).is_file():
171
- candidate_files.append(src_dir / name)
172
-
173
- import os
174
- for file_path in candidate_files:
175
- try:
176
- with open(file_path, "r") as f:
177
- content = f.read()
178
- except Exception:
179
- continue
180
-
181
- try:
182
- module_name = str(file_path.relative_to(project_root)).replace(os.sep, '.')[:-3]
183
- if module_name.startswith("src."):
184
- module_name = module_name[4:]
185
- except ValueError:
186
- module_name = file_path.stem
187
-
188
- if "FastAPI" in content:
189
- return "fastapi", 8000, f"uvicorn {module_name}:app --host 0.0.0.0 --port 8000"
190
-
191
- if "Flask" in content:
192
- return "flask", 5000, f"gunicorn {module_name}:app -b 0.0.0.0:5000"
193
-
194
- return None, None, None
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