xenfra 0.4.2__tar.gz → 0.4.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: A 'Zen Mode' infrastructure engine for Python developers.
5
5
  Author: xenfra-cloud
6
6
  Author-email: xenfra-cloud <xenfracloud@gmail.com>
@@ -1,56 +1,56 @@
1
- [project]
2
- name = "xenfra"
3
- version = "0.4.2"
4
- description = "A 'Zen Mode' infrastructure engine for Python developers."
5
- readme = "README.md"
6
- authors = [
7
- { name = "xenfra-cloud", email = "xenfracloud@gmail.com" }
8
- ]
9
-
10
- classifiers = [
11
- "Programming Language :: Python :: 3",
12
- "License :: OSI Approved :: MIT License",
13
- "Operating System :: OS Independent",
14
- "Development Status :: 3 - Alpha",
15
- "Intended Audience :: Developers",
16
- "Topic :: Software Development :: Build Tools",
17
- "Topic :: System :: Systems Administration",
18
- ]
19
-
20
- dependencies = [
21
- "click>=8.1.7",
22
- "rich>=14.2.0",
23
- "sqlmodel>=0.0.16",
24
- "python-digitalocean>=1.17.0",
25
- "python-dotenv>=1.2.1",
26
- "pyyaml>=6.0.1",
27
- "fabric>=3.2.2",
28
- "xenfra-sdk",
29
- "httpx>=0.27.0",
30
- "keyring>=25.7.0",
31
- "keyrings.alt>=5.0.2",
32
- "tenacity>=8.2.3", # For retry logic
33
- "cryptography>=43.0.0", # For encrypted file-based token storage
34
- "toml>=0.10.2",
35
- ]
36
- requires-python = ">=3.13"
37
-
38
- [tool.uv.sources]
39
- xenfra-sdk = { workspace = true }
40
-
41
- [project.urls]
42
- Homepage = "https://github.com/xenfra-cloud/xenfra"
43
- Issues = "https://github.com/xenfra-cloud/xenfra/issues"
44
-
45
- [project.optional-dependencies]
46
- test = [
47
- "pytest>=8.0.0",
48
- "pytest-mock>=3.12.0",
49
- ]
50
-
51
- [project.scripts]
52
- xenfra = "xenfra.main:main"
53
-
54
- [build-system]
55
- requires = ["uv_build>=0.9.18,<0.10.0"]
56
- build-backend = "uv_build"
1
+ [project]
2
+ name = "xenfra"
3
+ version = "0.4.3"
4
+ description = "A 'Zen Mode' infrastructure engine for Python developers."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "xenfra-cloud", email = "xenfracloud@gmail.com" }
8
+ ]
9
+
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Topic :: Software Development :: Build Tools",
17
+ "Topic :: System :: Systems Administration",
18
+ ]
19
+
20
+ dependencies = [
21
+ "click>=8.1.7",
22
+ "rich>=14.2.0",
23
+ "sqlmodel>=0.0.16",
24
+ "python-digitalocean>=1.17.0",
25
+ "python-dotenv>=1.2.1",
26
+ "pyyaml>=6.0.1",
27
+ "fabric>=3.2.2",
28
+ "xenfra-sdk",
29
+ "httpx>=0.27.0",
30
+ "keyring>=25.7.0",
31
+ "keyrings.alt>=5.0.2",
32
+ "tenacity>=8.2.3", # For retry logic
33
+ "cryptography>=43.0.0", # For encrypted file-based token storage
34
+ "toml>=0.10.2",
35
+ ]
36
+ requires-python = ">=3.13"
37
+
38
+ [tool.uv.sources]
39
+ xenfra-sdk = { workspace = true }
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/xenfra-cloud/xenfra"
43
+ Issues = "https://github.com/xenfra-cloud/xenfra/issues"
44
+
45
+ [project.optional-dependencies]
46
+ test = [
47
+ "pytest>=8.0.0",
48
+ "pytest-mock>=3.12.0",
49
+ ]
50
+
51
+ [project.scripts]
52
+ xenfra = "xenfra.main:main"
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.9.18,<0.10.0"]
56
+ build-backend = "uv_build"
@@ -65,7 +65,7 @@ def show_patch_preview(patch_data: dict):
65
65
  console.print()
66
66
 
67
67
 
68
- def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, database: str = None):
68
+ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None):
69
69
  """
70
70
  Creates deployment with real-time SSE streaming (no polling needed).
71
71
 
@@ -93,8 +93,13 @@ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, b
93
93
  is_dockerized=is_dockerized,
94
94
  port=port,
95
95
  command=command,
96
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
96
97
  database=database,
98
+ package_manager=package_manager,
99
+ dependency_file=dependency_file,
100
+ file_manifest=file_manifest,
97
101
  ):
102
+
98
103
  event_type = event.get("event", "message")
99
104
  data = event.get("data", "")
100
105
 
@@ -359,6 +364,8 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
359
364
  # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
360
365
  project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
361
366
  framework = framework or config.get("framework")
367
+ # Track if is_dockerized was explicitly set in config (to avoid AI override)
368
+ is_dockerized_from_config = "is_dockerized" in config
362
369
  is_dockerized = config.get("is_dockerized", True)
363
370
  region = region or config.get("region") or "nyc3"
364
371
 
@@ -379,10 +386,15 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
379
386
  size = "s-1vcpu-1gb"
380
387
 
381
388
  # Extract port, command, database from config
382
- port = config.get("port", 8000)
389
+ # Track if port was explicitly set to avoid AI override
390
+ port_from_config = config.get("port")
391
+ port = port_from_config or 8000
383
392
  command = config.get("command") # Auto-detected if not provided
393
+ entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
384
394
  database_config = config.get("database", {})
385
395
  database = database_config.get("type") if isinstance(database_config, dict) else None
396
+ package_manager = config.get("package_manager", "pip")
397
+ dependency_file = config.get("dependency_file", "requirements.txt")
386
398
 
387
399
  # Default project name to current directory
388
400
  if not project_name:
@@ -403,9 +415,10 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
403
415
  console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
404
416
  console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
405
417
  else:
406
- console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
407
- console.print("[dim]Code will be uploaded to the droplet[/dim]")
408
- # Note: SDK's InfraEngine handles local upload via fabric.transfer.Upload()
418
+ # Note: Local folder deployment only works when engine runs locally
419
+ # In cloud API mode, this will fail with a clear error from the server
420
+ console.print(f"[cyan]Deploying {project_name}...[/cyan]")
421
+ console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
409
422
 
410
423
  # Validate branch name
411
424
  is_valid, error_msg = validate_branch_name(branch)
@@ -427,10 +440,6 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
427
440
  try:
428
441
  with get_client() as client:
429
442
  while attempt < MAX_RETRY_ATTEMPTS:
430
- # Safety check to prevent infinite loops
431
- if attempt > MAX_RETRY_ATTEMPTS:
432
- raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
433
-
434
443
  attempt += 1
435
444
 
436
445
  if attempt > 1:
@@ -449,13 +458,18 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
449
458
  if code_snippets:
450
459
  analysis = client.intelligence.analyze_codebase(code_snippets)
451
460
  framework = analysis.framework
452
- is_dockerized = analysis.is_dockerized
461
+ # Only use AI's is_dockerized if config didn't explicitly set it
462
+ if not is_dockerized_from_config:
463
+ is_dockerized = analysis.is_dockerized
464
+ # Override port if AI detected it and config didn't set one
465
+ if not port_from_config and analysis.port:
466
+ port = analysis.port
453
467
  # Override port and size if AI has strong recommendations
454
468
  if not size and analysis.instance_size:
455
469
  size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
456
470
 
457
471
  mode_str = "Docker" if is_dockerized else "Bare Metal"
458
- console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {analysis.port})")
472
+ console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
459
473
  else:
460
474
  console.print("[yellow]⚠ No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
461
475
  framework = "fastapi"
@@ -465,6 +479,48 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
465
479
  framework = "fastapi"
466
480
  is_dockerized = True
467
481
 
482
+ # Delta upload: if no git_repo, scan and upload local files
483
+ file_manifest = None
484
+ if not git_repo:
485
+ from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
486
+
487
+ # Protect privacy: ensure .xenfra is in .gitignore
488
+ if ensure_gitignore_ignored():
489
+ console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
490
+
491
+ console.print("[cyan]📁 Scanning project files...[/cyan]")
492
+
493
+ file_manifest = scan_project_files_cached()
494
+ console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
495
+
496
+ if not file_manifest:
497
+ console.print("[bold red]Error: No files found to deploy.[/bold red]")
498
+ raise click.Abort()
499
+
500
+ # Check which files need uploading
501
+ console.print("[cyan]🔍 Checking file cache...[/cyan]")
502
+ check_result = client.files.check(file_manifest)
503
+ missing = check_result.get('missing', [])
504
+ cached = check_result.get('cached', 0)
505
+
506
+ if cached > 0:
507
+ console.print(f"[green]✓ {cached} files already cached[/green]")
508
+
509
+ # Upload missing files
510
+ if missing:
511
+ console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
512
+ uploaded = client.files.upload_files(
513
+ file_manifest,
514
+ missing,
515
+ progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
516
+ )
517
+ console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
518
+ else:
519
+ console.print("[green]✓ All files already cached[/green]")
520
+
521
+ # Remove abs_path from manifest before sending to API
522
+ file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
523
+
468
524
  # Create deployment with real-time streaming
469
525
  try:
470
526
  status_result, deployment_id, logs_data = _stream_deployment(
@@ -478,9 +534,14 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
478
534
  is_dockerized=is_dockerized,
479
535
  port=port,
480
536
  command=command,
537
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
481
538
  database=database,
539
+ package_manager=package_manager,
540
+ dependency_file=dependency_file,
541
+ file_manifest=file_manifest,
482
542
  )
483
543
 
544
+
484
545
  if status_result == "FAILED" and not no_heal:
485
546
  # Hand off to the Zen Nod AI Agent
486
547
  should_retry = zen_nod_workflow(logs_data, client, attempt)
@@ -67,6 +67,10 @@ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xe
67
67
  "port": analysis.port,
68
68
  }
69
69
 
70
+ # Add entrypoint if detected (e.g., "todo.main:app")
71
+ if hasattr(analysis, 'entrypoint') and analysis.entrypoint:
72
+ config["entrypoint"] = analysis.entrypoint
73
+
70
74
  # Add database configuration if detected
71
75
  if analysis.database and analysis.database != "none":
72
76
  config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
@@ -0,0 +1,286 @@
1
+ """
2
+ File synchronization utilities for delta uploads.
3
+
4
+ Provides functions to scan project files, compute SHA256 hashes,
5
+ and manage local file caches for incremental deployments.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Set
14
+
15
+ # Patterns to exclude from deployment
16
+ EXCLUDE_PATTERNS: Set[str] = {
17
+ # Version control
18
+ '.git',
19
+ '.svn',
20
+ '.hg',
21
+
22
+ # Python
23
+ '.venv',
24
+ 'venv',
25
+ '__pycache__',
26
+ '*.pyc',
27
+ '*.pyo',
28
+ '.pytest_cache',
29
+ '.mypy_cache',
30
+ '*.egg-info',
31
+ 'dist',
32
+ 'build',
33
+
34
+ # Node.js
35
+ 'node_modules',
36
+
37
+ # IDE/Editor
38
+ '.idea',
39
+ '.vscode',
40
+ '*.swp',
41
+
42
+ # Xenfra
43
+ '.xenfra',
44
+
45
+ # Environment
46
+ '.env',
47
+ '.env.local',
48
+ '.env.*.local',
49
+
50
+ # OS
51
+ '.DS_Store',
52
+ 'Thumbs.db',
53
+ }
54
+
55
+ # File extensions to always exclude
56
+ EXCLUDE_EXTENSIONS: Set[str] = {
57
+ '.pyc', '.pyo', '.so', '.dylib', '.dll',
58
+ '.exe', '.bin', '.obj', '.o',
59
+ }
60
+
61
+
62
+ def should_exclude(path: Path, root: Path) -> bool:
63
+ """Check if a path should be excluded from upload."""
64
+ rel_parts = path.relative_to(root).parts
65
+
66
+ # Check each part of the path against exclusion patterns
67
+ for part in rel_parts:
68
+ if part in EXCLUDE_PATTERNS:
69
+ return True
70
+ # Check wildcard patterns
71
+ for pattern in EXCLUDE_PATTERNS:
72
+ if pattern.startswith('*') and part.endswith(pattern[1:]):
73
+ return True
74
+
75
+ # Check file extension
76
+ if path.suffix.lower() in EXCLUDE_EXTENSIONS:
77
+ return True
78
+
79
+ return False
80
+
81
+
82
+ def compute_file_sha(filepath: str) -> str:
83
+ """Compute SHA256 hash of a file's content."""
84
+ sha256 = hashlib.sha256()
85
+ with open(filepath, 'rb') as f:
86
+ for chunk in iter(lambda: f.read(8192), b''):
87
+ sha256.update(chunk)
88
+ return sha256.hexdigest()
89
+
90
+
91
+ def scan_project_files(root: str = '.') -> List[Dict]:
92
+ """
93
+ Scan project directory and return list of files with their metadata.
94
+
95
+ Returns:
96
+ List of dicts with keys: path, sha, size, abs_path
97
+ """
98
+ files = []
99
+ root_path = Path(root).resolve()
100
+
101
+ for filepath in root_path.rglob('*'):
102
+ # Skip directories
103
+ if not filepath.is_file():
104
+ continue
105
+
106
+ # Check exclusions
107
+ if should_exclude(filepath, root_path):
108
+ continue
109
+
110
+ # Skip very large files (> 50MB)
111
+ file_size = filepath.stat().st_size
112
+ if file_size > 50 * 1024 * 1024:
113
+ continue
114
+
115
+ # Normalize path to use forward slashes
116
+ rel_path = str(filepath.relative_to(root_path)).replace('\\', '/')
117
+
118
+ files.append({
119
+ 'path': rel_path,
120
+ 'sha': compute_file_sha(str(filepath)),
121
+ 'size': file_size,
122
+ 'abs_path': str(filepath),
123
+ })
124
+
125
+ return files
126
+
127
+
128
+ def get_xenfra_dir(project_root: str = '.') -> Path:
129
+ """Get or create the .xenfra directory."""
130
+ xenfra_dir = Path(project_root).resolve() / '.xenfra'
131
+ xenfra_dir.mkdir(exist_ok=True)
132
+
133
+ # Create cache subdirectory
134
+ cache_dir = xenfra_dir / 'cache'
135
+ cache_dir.mkdir(exist_ok=True)
136
+
137
+ return xenfra_dir
138
+
139
+
140
+ def load_file_cache(project_root: str = '.') -> Dict[str, Dict]:
141
+ """
142
+ Load cached file hashes from .xenfra/cache/file_hashes.json.
143
+
144
+ Returns:
145
+ Dict mapping file paths to {sha, mtime, size}
146
+ """
147
+ xenfra_dir = get_xenfra_dir(project_root)
148
+ cache_file = xenfra_dir / 'cache' / 'file_hashes.json'
149
+
150
+ if cache_file.exists():
151
+ try:
152
+ with open(cache_file, 'r') as f:
153
+ return json.load(f)
154
+ except (json.JSONDecodeError, IOError):
155
+ return {}
156
+ return {}
157
+
158
+
159
+ def save_file_cache(cache: Dict[str, Dict], project_root: str = '.'):
160
+ """Save file hashes to .xenfra/cache/file_hashes.json."""
161
+ xenfra_dir = get_xenfra_dir(project_root)
162
+ cache_file = xenfra_dir / 'cache' / 'file_hashes.json'
163
+
164
+ with open(cache_file, 'w') as f:
165
+ json.dump(cache, f, indent=2)
166
+
167
+
168
+ def scan_project_files_cached(root: str = '.') -> List[Dict]:
169
+ """
170
+ Scan project files using local cache for unchanged files.
171
+
172
+ Only recomputes SHA for files whose mtime or size changed.
173
+ This is much faster for large projects with few changes.
174
+ """
175
+ files = []
176
+ root_path = Path(root).resolve()
177
+ cache = load_file_cache(root)
178
+ new_cache = {}
179
+
180
+ for filepath in root_path.rglob('*'):
181
+ if not filepath.is_file():
182
+ continue
183
+
184
+ if should_exclude(filepath, root_path):
185
+ continue
186
+
187
+ file_size = filepath.stat().st_size
188
+ if file_size > 50 * 1024 * 1024:
189
+ continue
190
+
191
+ rel_path = str(filepath.relative_to(root_path)).replace('\\', '/')
192
+ mtime = filepath.stat().st_mtime
193
+
194
+ # Check if we can use cached value
195
+ cached = cache.get(rel_path)
196
+ if cached and cached.get('mtime') == mtime and cached.get('size') == file_size:
197
+ sha = cached['sha']
198
+ else:
199
+ # File changed, recompute SHA
200
+ sha = compute_file_sha(str(filepath))
201
+
202
+ # Update cache
203
+ new_cache[rel_path] = {
204
+ 'sha': sha,
205
+ 'mtime': mtime,
206
+ 'size': file_size,
207
+ }
208
+
209
+ files.append({
210
+ 'path': rel_path,
211
+ 'sha': sha,
212
+ 'size': file_size,
213
+ 'abs_path': str(filepath),
214
+ })
215
+
216
+ # Save updated cache
217
+ save_file_cache(new_cache, root)
218
+
219
+ return files
220
+
221
+
222
+ def load_project_config(project_root: str = '.') -> Optional[Dict]:
223
+ """Load .xenfra/config.json if it exists."""
224
+ xenfra_dir = Path(project_root).resolve() / '.xenfra'
225
+ config_file = xenfra_dir / 'config.json'
226
+
227
+ if config_file.exists():
228
+ try:
229
+ with open(config_file, 'r') as f:
230
+ return json.load(f)
231
+ except (json.JSONDecodeError, IOError):
232
+ return None
233
+ return None
234
+
235
+
236
+ def ensure_gitignore_ignored(project_root: str = '.'):
237
+ """Ensure .xenfra/ is in the .gitignore file."""
238
+ root_path = Path(project_root).resolve()
239
+ gitignore_path = root_path / '.gitignore'
240
+
241
+ entry = '.xenfra/\n'
242
+
243
+ if not gitignore_path.exists():
244
+ try:
245
+ with open(gitignore_path, 'w') as f:
246
+ f.write(entry)
247
+ return True
248
+ except IOError:
249
+ return False
250
+
251
+ try:
252
+ with open(gitignore_path, 'r') as f:
253
+ content = f.read()
254
+
255
+ if '.xenfra/' not in content and '.xenfra' not in content:
256
+ with open(gitignore_path, 'a') as f:
257
+ if not content.endswith('\n'):
258
+ f.write('\n')
259
+ f.write(entry)
260
+ return True
261
+ except IOError:
262
+ return False
263
+
264
+ return False
265
+
266
+
267
+ def save_project_config(config: Dict, project_root: str = '.'):
268
+ """Save project config to .xenfra/config.json."""
269
+ xenfra_dir = get_xenfra_dir(project_root)
270
+ config_file = xenfra_dir / 'config.json'
271
+
272
+ with open(config_file, 'w') as f:
273
+ json.dump(config, f, indent=2)
274
+
275
+
276
+ def update_last_deployment(deployment_id: str, url: str = None, project_root: str = '.'):
277
+ """Update the last deployment info in project config."""
278
+ config = load_project_config(project_root) or {}
279
+
280
+ config['lastDeployment'] = {
281
+ 'id': deployment_id,
282
+ 'url': url,
283
+ 'createdAt': datetime.utcnow().isoformat() + 'Z',
284
+ }
285
+
286
+ save_project_config(config, project_root)
File without changes
File without changes
File without changes
File without changes