xenfra 0.4.3__py3-none-any.whl → 0.4.5__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,182 @@
1
+ """
2
+ Tier 1: Local validation - Fast syntax and import checking.
3
+
4
+ This module provides lightning-fast (~100ms) validation of project files
5
+ before any expensive operations. It checks:
6
+ - Python syntax errors
7
+ - Import errors (missing dependencies)
8
+ - Config file validity
9
+ - Basic project structure
10
+ """
11
+
12
+ import ast
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional
17
+ from dataclasses import dataclass, field
18
+
19
+
20
+ @dataclass
21
+ class ValidationResult:
22
+ """Result of Tier 1 validation."""
23
+ success: bool
24
+ errors: list[str] = field(default_factory=list)
25
+ warnings: list[str] = field(default_factory=list)
26
+
27
+ def add_error(self, message: str):
28
+ self.errors.append(message)
29
+ self.success = False
30
+
31
+ def add_warning(self, message: str):
32
+ self.warnings.append(message)
33
+
34
+
35
+ class LocalValidator:
36
+ """Fast local validation for common project issues."""
37
+
38
+ def validate(self, project_path: Path) -> ValidationResult:
39
+ """
40
+ Run all Tier 1 validations.
41
+
42
+ Args:
43
+ project_path: Path to the project directory
44
+
45
+ Returns:
46
+ ValidationResult with any errors or warnings
47
+ """
48
+ result = ValidationResult(success=True)
49
+
50
+ # Check Python files for syntax errors
51
+ self._validate_python_syntax(project_path, result)
52
+
53
+ # Validate config files
54
+ self._validate_configs(project_path, result)
55
+
56
+ # Check for required files
57
+ self._validate_required_files(project_path, result)
58
+
59
+ return result
60
+
61
+ def _validate_python_syntax(self, project_path: Path, result: ValidationResult):
62
+ """Check all Python files for syntax errors."""
63
+ python_files = list(project_path.rglob("*.py"))
64
+
65
+ for py_file in python_files:
66
+ # Skip common virtual environment directories
67
+ if any(part.startswith(".") or part in ["venv", ".venv", "env", ".env", "__pycache__"]
68
+ for part in py_file.parts):
69
+ continue
70
+
71
+ try:
72
+ content = py_file.read_text(encoding="utf-8")
73
+ ast.parse(content)
74
+ except SyntaxError as e:
75
+ result.add_error(
76
+ f"Syntax error in {py_file.relative_to(project_path)}:{e.lineno}: {e.msg}"
77
+ )
78
+ except UnicodeDecodeError:
79
+ result.add_warning(f"Could not read {py_file.relative_to(project_path)} (encoding issue)")
80
+
81
+ def _validate_configs(self, project_path: Path, result: ValidationResult):
82
+ """Validate configuration files."""
83
+ # requirements.txt - check format
84
+ req_file = project_path / "requirements.txt"
85
+ if req_file.exists():
86
+ try:
87
+ content = req_file.read_text(encoding="utf-8")
88
+ for line_num, line in enumerate(content.split("\n"), 1):
89
+ line = line.strip()
90
+ if not line or line.startswith("#"):
91
+ continue
92
+ # Basic format check
93
+ if ";" in line and "sys_platform" not in line and "python_version" not in line:
94
+ # Might be an error, but could also be valid
95
+ pass
96
+ except Exception as e:
97
+ result.add_error(f"Error reading requirements.txt: {e}")
98
+
99
+ # package.json - validate JSON
100
+ pkg_file = project_path / "package.json"
101
+ if pkg_file.exists():
102
+ try:
103
+ content = pkg_file.read_text(encoding="utf-8")
104
+ json.loads(content)
105
+ except json.JSONDecodeError as e:
106
+ result.add_error(f"Invalid package.json: {e}")
107
+
108
+ # pyproject.toml - basic check
109
+ pyproject = project_path / "pyproject.toml"
110
+ if pyproject.exists():
111
+ try:
112
+ import tomllib
113
+ content = pyproject.read_bytes()
114
+ tomllib.loads(content.decode("utf-8"))
115
+ except ImportError:
116
+ # Python < 3.11, try toml library
117
+ try:
118
+ import toml
119
+ toml.load(pyproject)
120
+ except ImportError:
121
+ pass # Skip if no toml library
122
+ except Exception as e:
123
+ result.add_error(f"Invalid pyproject.toml: {e}")
124
+ except Exception as e:
125
+ result.add_error(f"Invalid pyproject.toml: {e}")
126
+
127
+ def _validate_required_files(self, project_path: Path, result: ValidationResult):
128
+ """Check for required files based on detected project type."""
129
+ # Detect project type
130
+ has_requirements = (project_path / "requirements.txt").exists()
131
+ has_pyproject = (project_path / "pyproject.toml").exists()
132
+ has_package_json = (project_path / "package.json").exists()
133
+ has_go_mod = (project_path / "go.mod").exists()
134
+ has_cargo = (project_path / "Cargo.toml").exists()
135
+
136
+ # Check for main entry point (Python)
137
+ if has_requirements or has_pyproject:
138
+ main_files = [
139
+ project_path / "main.py",
140
+ project_path / "app.py",
141
+ project_path / "manage.py", # Django
142
+ ]
143
+ app_dirs = [
144
+ project_path / "app",
145
+ project_path / project_path.name.lower().replace("-", "_").replace(" ", "_"),
146
+ ]
147
+
148
+ if not any(f.exists() for f in main_files):
149
+ # Check if there's a package directory with __main__.py or similar
150
+ has_entry = False
151
+ for app_dir in app_dirs:
152
+ if app_dir.is_dir():
153
+ if (app_dir / "__main__.py").exists() or (app_dir / "main.py").exists():
154
+ has_entry = True
155
+ break
156
+
157
+ if not has_entry:
158
+ result.add_warning(
159
+ "No clear Python entry point found (main.py, app.py, or package/__main__.py)"
160
+ )
161
+
162
+ # Check for main entry point (Node.js)
163
+ if has_package_json:
164
+ pkg_json = json.loads((project_path / "package.json").read_text())
165
+ if "main" not in pkg_json and "start" not in str(pkg_json.get("scripts", {})):
166
+ result.add_warning(
167
+ "No 'main' field or 'start' script found in package.json"
168
+ )
169
+
170
+
171
+ def validate_project(project_path: Path) -> ValidationResult:
172
+ """
173
+ Convenience function for Tier 1 validation.
174
+
175
+ Args:
176
+ project_path: Path to the project directory
177
+
178
+ Returns:
179
+ ValidationResult with errors and warnings
180
+ """
181
+ validator = LocalValidator()
182
+ return validator.validate(project_path)
@@ -1,3 +1,3 @@
1
- """
2
- CLI command modules for Xenfra.
3
- """
1
+ """
2
+ CLI command modules for Xenfra.
3
+ """
xenfra/commands/auth.py CHANGED
@@ -1,144 +1,144 @@
1
- """
2
- Authentication commands for Xenfra CLI.
3
- """
4
-
5
- import base64
6
- import hashlib
7
- import secrets
8
- import urllib.parse
9
- import webbrowser
10
- from http.server import HTTPServer
11
-
12
- import click
13
- import httpx
14
- import keyring
15
- from rich.console import Console
16
- from tenacity import (
17
- retry,
18
- retry_if_exception_type,
19
- stop_after_attempt,
20
- wait_exponential,
21
- )
22
-
23
- from ..utils.auth import (
24
- API_BASE_URL,
25
- CLI_CLIENT_ID,
26
- CLI_LOCAL_SERVER_END_PORT,
27
- CLI_LOCAL_SERVER_START_PORT,
28
- CLI_REDIRECT_PATH,
29
- SERVICE_ID,
30
- AuthCallbackHandler,
31
- clear_tokens,
32
- get_auth_token,
33
- )
34
-
35
- console = Console()
36
-
37
- # HTTP request timeout (30 seconds)
38
- HTTP_TIMEOUT = 30.0
39
-
40
-
41
- @click.group()
42
- def auth():
43
- """Authentication commands (login, logout, whoami)."""
44
- pass
45
-
46
-
47
- @retry(
48
- stop=stop_after_attempt(3),
49
- wait=wait_exponential(multiplier=1, min=2, max=10),
50
- retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
- reraise=True,
52
- )
53
- def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
- """
55
- Exchange authorization code for tokens with retry logic.
56
-
57
- Returns token data dictionary.
58
- """
59
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
- response = client.post(
61
- f"{API_BASE_URL}/auth/token",
62
- data={
63
- "grant_type": "authorization_code",
64
- "client_id": CLI_CLIENT_ID,
65
- "code": code,
66
- "code_verifier": code_verifier,
67
- "redirect_uri": redirect_uri,
68
- },
69
- headers={"Accept": "application/json"},
70
- )
71
- response.raise_for_status()
72
-
73
- # Safe JSON parsing with content-type check
74
- content_type = response.headers.get("content-type", "")
75
- if "application/json" not in content_type:
76
- raise ValueError(f"Expected JSON response, got {content_type}")
77
-
78
- try:
79
- token_data = response.json()
80
- except (ValueError, TypeError) as e:
81
- raise ValueError(f"Failed to parse JSON response: {e}")
82
-
83
- return token_data
84
-
85
-
86
- @auth.command()
87
- def login():
88
- """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
- from .auth_device import device_login
90
- device_login()
91
-
92
- # Removed old PKCE flow - now using Device Authorization Flow
93
-
94
-
95
- @auth.command()
96
- def logout():
97
- """Logout and clear stored tokens."""
98
- try:
99
- clear_tokens()
100
- console.print("[bold green]Logged out successfully.[/bold green]")
101
- except Exception as e:
102
- console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
103
- console.print("[dim]Tokens may still be stored in keyring.[/dim]")
104
-
105
-
106
- @auth.command()
107
- @click.option("--token", is_flag=True, help="Show access token")
108
- def whoami(token):
109
- """Show current authenticated user."""
110
- access_token = get_auth_token()
111
-
112
- if not access_token:
113
- console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
114
- return
115
-
116
- try:
117
- import base64
118
- import json
119
-
120
- # Manually decode JWT payload without verification
121
- # JWT format: header.payload.signature
122
- parts = access_token.split(".")
123
- if len(parts) != 3:
124
- console.print("[bold red]Invalid token format[/bold red]")
125
- return
126
-
127
- # Decode payload (second part)
128
- payload_b64 = parts[1]
129
- # Add padding if needed
130
- padding = 4 - len(payload_b64) % 4
131
- if padding != 4:
132
- payload_b64 += "=" * padding
133
-
134
- payload_bytes = base64.urlsafe_b64decode(payload_b64)
135
- claims = json.loads(payload_bytes)
136
-
137
- console.print("[bold green]Logged in as:[/bold green]")
138
- console.print(f" Email: {claims.get('sub', 'N/A')}")
139
- console.print(f" User ID: {claims.get('user_id', 'N/A')}")
140
-
141
- if token:
142
- console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
143
- except Exception as e:
144
- console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
1
+ """
2
+ Authentication commands for Xenfra CLI.
3
+ """
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import urllib.parse
9
+ import webbrowser
10
+ from http.server import HTTPServer
11
+
12
+ import click
13
+ import httpx
14
+ import keyring
15
+ from rich.console import Console
16
+ from tenacity import (
17
+ retry,
18
+ retry_if_exception_type,
19
+ stop_after_attempt,
20
+ wait_exponential,
21
+ )
22
+
23
+ from ..utils.auth import (
24
+ API_BASE_URL,
25
+ CLI_CLIENT_ID,
26
+ CLI_LOCAL_SERVER_END_PORT,
27
+ CLI_LOCAL_SERVER_START_PORT,
28
+ CLI_REDIRECT_PATH,
29
+ SERVICE_ID,
30
+ AuthCallbackHandler,
31
+ clear_tokens,
32
+ get_auth_token,
33
+ )
34
+
35
+ console = Console()
36
+
37
+ # HTTP request timeout (30 seconds)
38
+ HTTP_TIMEOUT = 30.0
39
+
40
+
41
+ @click.group()
42
+ def auth():
43
+ """Authentication commands (login, logout, whoami)."""
44
+ pass
45
+
46
+
47
+ @retry(
48
+ stop=stop_after_attempt(3),
49
+ wait=wait_exponential(multiplier=1, min=2, max=10),
50
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
+ reraise=True,
52
+ )
53
+ def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
+ """
55
+ Exchange authorization code for tokens with retry logic.
56
+
57
+ Returns token data dictionary.
58
+ """
59
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
+ response = client.post(
61
+ f"{API_BASE_URL}/auth/token",
62
+ data={
63
+ "grant_type": "authorization_code",
64
+ "client_id": CLI_CLIENT_ID,
65
+ "code": code,
66
+ "code_verifier": code_verifier,
67
+ "redirect_uri": redirect_uri,
68
+ },
69
+ headers={"Accept": "application/json"},
70
+ )
71
+ response.raise_for_status()
72
+
73
+ # Safe JSON parsing with content-type check
74
+ content_type = response.headers.get("content-type", "")
75
+ if "application/json" not in content_type:
76
+ raise ValueError(f"Expected JSON response, got {content_type}")
77
+
78
+ try:
79
+ token_data = response.json()
80
+ except (ValueError, TypeError) as e:
81
+ raise ValueError(f"Failed to parse JSON response: {e}")
82
+
83
+ return token_data
84
+
85
+
86
+ @auth.command()
87
+ def login():
88
+ """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
+ from .auth_device import device_login
90
+ device_login()
91
+
92
+ # Removed old PKCE flow - now using Device Authorization Flow
93
+
94
+
95
+ @auth.command()
96
+ def logout():
97
+ """Logout and clear stored tokens."""
98
+ try:
99
+ clear_tokens()
100
+ console.print("[bold green]Logged out successfully.[/bold green]")
101
+ except Exception as e:
102
+ console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
103
+ console.print("[dim]Tokens may still be stored in keyring.[/dim]")
104
+
105
+
106
+ @auth.command()
107
+ @click.option("--token", is_flag=True, help="Show access token")
108
+ def whoami(token):
109
+ """Show current authenticated user."""
110
+ access_token = get_auth_token()
111
+
112
+ if not access_token:
113
+ console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
114
+ return
115
+
116
+ try:
117
+ import base64
118
+ import json
119
+
120
+ # Manually decode JWT payload without verification
121
+ # JWT format: header.payload.signature
122
+ parts = access_token.split(".")
123
+ if len(parts) != 3:
124
+ console.print("[bold red]Invalid token format[/bold red]")
125
+ return
126
+
127
+ # Decode payload (second part)
128
+ payload_b64 = parts[1]
129
+ # Add padding if needed
130
+ padding = 4 - len(payload_b64) % 4
131
+ if padding != 4:
132
+ payload_b64 += "=" * padding
133
+
134
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
135
+ claims = json.loads(payload_bytes)
136
+
137
+ console.print("[bold green]Logged in as:[/bold green]")
138
+ console.print(f" Email: {claims.get('sub', 'N/A')}")
139
+ console.print(f" User ID: {claims.get('user_id', 'N/A')}")
140
+
141
+ if token:
142
+ console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
143
+ except Exception as e:
144
+ console.print(f"[bold red]Failed to decode token: {e}[/bold red]")