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.
xenfra_sdk/exceptions.py CHANGED
@@ -1,19 +1,19 @@
1
- class XenfraError(Exception):
2
- """Base exception for all SDK errors."""
3
-
4
- pass
5
-
6
-
7
- class AuthenticationError(XenfraError):
8
- """Raised for issues related to authentication."""
9
-
10
- pass
11
-
12
-
13
- class XenfraAPIError(XenfraError):
14
- """Raised when the API returns a non-2xx status code."""
15
-
16
- def __init__(self, status_code: int, detail: str):
17
- self.status_code = status_code
18
- self.detail = detail
19
- super().__init__(f"API Error {status_code}: {detail}")
1
+ class XenfraError(Exception):
2
+ """Base exception for all SDK errors."""
3
+
4
+ pass
5
+
6
+
7
+ class AuthenticationError(XenfraError):
8
+ """Raised for issues related to authentication."""
9
+
10
+ pass
11
+
12
+
13
+ class XenfraAPIError(XenfraError):
14
+ """Raised when the API returns a non-2xx status code."""
15
+
16
+ def __init__(self, status_code: int, detail: str):
17
+ self.status_code = status_code
18
+ self.detail = detail
19
+ super().__init__(f"API Error {status_code}: {detail}")
xenfra_sdk/manifest.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ Xenfra Microservices Manifest - Schema and Parser for xenfra.yaml services array.
3
+
4
+ This module defines the Pydantic models for microservices deployment configuration.
5
+ For microservices projects, xenfra.yaml includes a 'services' array:
6
+
7
+ project_name: my-app
8
+ framework: fastapi
9
+ services: # <-- This makes it a microservices project
10
+ - name: users
11
+ port: 8001
12
+ - name: orders
13
+ port: 8002
14
+ """
15
+
16
+ from pathlib import Path
17
+ from typing import List, Literal, Optional
18
+
19
+ import yaml
20
+ from pydantic import BaseModel, Field, field_validator
21
+
22
+
23
+ class ServiceDefinition(BaseModel):
24
+ """
25
+ Single service definition in a microservices project.
26
+
27
+ Example in xenfra.yaml:
28
+ services:
29
+ - name: users
30
+ path: ./services/users
31
+ port: 8001
32
+ framework: fastapi
33
+ entrypoint: users_api.main:app
34
+ """
35
+
36
+ name: str = Field(..., min_length=1, max_length=50, description="Service name (unique)")
37
+ path: str = Field(default=".", description="Relative path to service directory")
38
+ port: int = Field(..., ge=1, le=65535, description="Service port")
39
+ framework: Literal["fastapi", "flask", "django", "other"] = Field(
40
+ default="fastapi", description="Web framework"
41
+ )
42
+ entrypoint: Optional[str] = Field(
43
+ default=None, description="Application entrypoint (e.g., 'users_api.main:app')"
44
+ )
45
+ command: Optional[str] = Field(
46
+ default=None, description="Custom start command"
47
+ )
48
+ env: Optional[dict] = Field(
49
+ default_factory=dict, description="Environment variables"
50
+ )
51
+ package_manager: Optional[str] = Field(
52
+ default="pip", description="Package manager (pip, uv)"
53
+ )
54
+ dependency_file: Optional[str] = Field(
55
+ default="requirements.txt", description="Dependency file"
56
+ )
57
+ missing_deps: List[str] = Field(
58
+ default_factory=list, description="Proactively detected missing dependencies"
59
+ )
60
+
61
+ @field_validator("name")
62
+ @classmethod
63
+ def validate_name(cls, v: str) -> str:
64
+ """Ensure name is URL-safe (alphanumeric + hyphens)."""
65
+ import re
66
+ if not re.match(r'^[a-z][a-z0-9-]*$', v.lower()):
67
+ raise ValueError(
68
+ f"Service name '{v}' must start with a letter and contain only "
69
+ "lowercase letters, numbers, and hyphens"
70
+ )
71
+ return v.lower()
72
+
73
+
74
+ def validate_unique_names(services: List[ServiceDefinition]) -> List[ServiceDefinition]:
75
+ """Ensure all service names are unique."""
76
+ names = [s.name for s in services]
77
+ if len(names) != len(set(names)):
78
+ duplicates = [n for n in names if names.count(n) > 1]
79
+ raise ValueError(f"Duplicate service names: {set(duplicates)}")
80
+ return services
81
+
82
+
83
+ def validate_unique_ports(services: List[ServiceDefinition]) -> List[ServiceDefinition]:
84
+ """Ensure all service ports are unique."""
85
+ ports = [s.port for s in services]
86
+ if len(ports) != len(set(ports)):
87
+ duplicates = [p for p in ports if ports.count(p) > 1]
88
+ raise ValueError(f"Duplicate service ports: {set(duplicates)}")
89
+ return services
90
+
91
+
92
+ def load_services_from_xenfra_yaml(project_path: str = ".") -> Optional[List[ServiceDefinition]]:
93
+ """
94
+ Load services array from xenfra.yaml if present.
95
+
96
+ Args:
97
+ project_path: Path to the project directory (default: current directory)
98
+
99
+ Returns:
100
+ List of ServiceDefinition if 'services' key found, None otherwise
101
+
102
+ Raises:
103
+ ValueError: If services array is invalid
104
+ """
105
+ yaml_path = Path(project_path) / "xenfra.yaml"
106
+
107
+ if not yaml_path.exists():
108
+ return None
109
+
110
+ with open(yaml_path, "r", encoding="utf-8") as f:
111
+ try:
112
+ data = yaml.safe_load(f)
113
+ except yaml.YAMLError:
114
+ return None
115
+
116
+ if not data or "services" not in data:
117
+ return None
118
+
119
+ services_data = data.get("services", [])
120
+ if not services_data or not isinstance(services_data, list):
121
+ return None
122
+
123
+ try:
124
+ services = [ServiceDefinition(**svc) for svc in services_data]
125
+ validate_unique_names(services)
126
+ validate_unique_ports(services)
127
+ return services
128
+ except Exception as e:
129
+ raise ValueError(f"Invalid services configuration in xenfra.yaml: {e}")
130
+
131
+
132
+ def is_microservices_project(project_path: str = ".") -> bool:
133
+ """
134
+ Check if project has multiple services defined in xenfra.yaml.
135
+
136
+ Returns:
137
+ True if xenfra.yaml has 'services' array with 2+ services
138
+ """
139
+ try:
140
+ services = load_services_from_xenfra_yaml(project_path)
141
+ return services is not None and len(services) > 1
142
+ except ValueError:
143
+ return False
144
+
145
+
146
+ def get_deployment_mode(project_path: str = ".") -> Optional[str]:
147
+ """
148
+ Get deployment mode from xenfra.yaml if specified.
149
+
150
+ Returns:
151
+ "single-droplet", "multi-droplet", or None if not specified
152
+ """
153
+ yaml_path = Path(project_path) / "xenfra.yaml"
154
+
155
+ if not yaml_path.exists():
156
+ return None
157
+
158
+ with open(yaml_path, "r", encoding="utf-8") as f:
159
+ try:
160
+ data = yaml.safe_load(f)
161
+ except yaml.YAMLError:
162
+ return None
163
+
164
+ return data.get("mode") if data else None
165
+
166
+
167
+ def add_services_to_xenfra_yaml(
168
+ project_path: str,
169
+ services: List[dict],
170
+ mode: str = "single-droplet"
171
+ ) -> Path:
172
+ """
173
+ Add or update services array in existing xenfra.yaml.
174
+
175
+ Args:
176
+ project_path: Path to the project directory
177
+ services: List of service dictionaries from auto-detection
178
+ mode: Deployment mode
179
+
180
+ Returns:
181
+ Path to the updated xenfra.yaml
182
+ """
183
+ yaml_path = Path(project_path) / "xenfra.yaml"
184
+
185
+ # Load existing xenfra.yaml if present
186
+ existing_data = {}
187
+ if yaml_path.exists():
188
+ with open(yaml_path, "r", encoding="utf-8") as f:
189
+ existing_data = yaml.safe_load(f) or {}
190
+
191
+ # Add services array
192
+ existing_data["services"] = services
193
+ existing_data["mode"] = mode
194
+
195
+ # Write back
196
+ with open(yaml_path, "w", encoding="utf-8") as f:
197
+ yaml.dump(existing_data, f, default_flow_style=False, sort_keys=False)
198
+
199
+ return yaml_path
200
+
201
+
202
+ def create_services_from_detected(services: List[dict]) -> List[ServiceDefinition]:
203
+ """
204
+ Create ServiceDefinition list from detected services.
205
+
206
+ Args:
207
+ services: List of service dictionaries from auto-detection
208
+
209
+ Returns:
210
+ List of ServiceDefinition instances
211
+ """
212
+ return [ServiceDefinition(**svc) for svc in services]
xenfra_sdk/mcp_client.py CHANGED
@@ -1,154 +1,154 @@
1
- # src/xenfra/mcp_client.py
2
-
3
- import base64
4
- import json
5
- import os
6
- import subprocess
7
- import tempfile
8
- from pathlib import Path
9
-
10
-
11
- class MCPClient:
12
- """
13
- A client for communicating with a local github-mcp-server process.
14
-
15
- This client starts the MCP server as a subprocess and interacts with it
16
- over stdin and stdout to download a full repository to a temporary directory.
17
- """
18
-
19
- def __init__(self, mcp_server_path="github-mcp-server"):
20
- """
21
- Initializes the MCPClient.
22
-
23
- Args:
24
- mcp_server_path (str): The path to the github-mcp-server executable.
25
- Assumes it's in the system's PATH by default.
26
- """
27
- self.mcp_server_path = mcp_server_path
28
- self.process = None
29
-
30
- def _start_server(self):
31
- """Starts the github-mcp-server subprocess."""
32
- if self.process and self.process.poll() is None:
33
- return
34
- try:
35
- self.process = subprocess.Popen(
36
- [self.mcp_server_path],
37
- stdin=subprocess.PIPE,
38
- stdout=subprocess.PIPE,
39
- stderr=subprocess.PIPE,
40
- text=True,
41
- env=os.environ,
42
- )
43
- except FileNotFoundError:
44
- raise RuntimeError(
45
- f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH."
46
- )
47
- except Exception as e:
48
- raise RuntimeError(f"Failed to start MCP server: {e}")
49
-
50
- def _stop_server(self):
51
- """Stops the MCP server process."""
52
- if self.process:
53
- self.process.terminate()
54
- self.process.wait()
55
- self.process = None
56
-
57
- def _send_request(self, method: str, params: dict) -> dict:
58
- """Sends a JSON-RPC request and returns the response."""
59
- if not self.process or self.process.poll() is not None:
60
- self._start_server()
61
-
62
- request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
63
-
64
- try:
65
- self.process.stdin.write(json.dumps(request) + "\n")
66
- self.process.stdin.flush()
67
- response_line = self.process.stdout.readline()
68
- if not response_line:
69
- error_output = self.process.stderr.read()
70
- raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
71
-
72
- response = json.loads(response_line)
73
- if "error" in response:
74
- raise RuntimeError(f"MCP server returned an error: {response['error']}")
75
- return response.get("result", {})
76
- except (BrokenPipeError, json.JSONDecodeError) as e:
77
- raise RuntimeError(f"Failed to communicate with MCP server: {e}")
78
-
79
- def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
80
- """
81
- Downloads an entire repository at a specific commit to a local temporary directory.
82
-
83
- Args:
84
- repo_url (str): The full URL of the GitHub repository.
85
- commit_sha (str): The commit SHA to download. Defaults to "HEAD".
86
-
87
- Returns:
88
- The path to the temporary directory containing the downloaded code.
89
- """
90
- try:
91
- parts = repo_url.strip("/").split("/")
92
- owner = parts[-2]
93
- repo_name = parts[-1].replace(".git", "")
94
- except IndexError:
95
- raise ValueError(
96
- "Invalid repository URL format. Expected format: https://github.com/owner/repo"
97
- )
98
-
99
- print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
100
- tree_result = self._send_request(
101
- method="git.get_repository_tree",
102
- params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True},
103
- )
104
-
105
- tree = tree_result.get("tree", [])
106
- if not tree:
107
- raise RuntimeError("Could not retrieve repository file tree.")
108
-
109
- temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
110
- print(f" [MCP] Downloading to temporary directory: {temp_dir}")
111
-
112
- for item in tree:
113
- item_path = item.get("path")
114
- item_type = item.get("type")
115
-
116
- if not item_path or item_type != "blob": # Only handle files
117
- continue
118
-
119
- # For downloading content, we can use the commit_sha in the 'ref' parameter
120
- # to ensure we get the content from the correct version.
121
- content_result = self._send_request(
122
- method="repos.get_file_contents",
123
- params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha},
124
- )
125
-
126
- content_b64 = content_result.get("content")
127
- if content_b64 is None:
128
- print(f" [MCP] [Warning] Could not get content for {item_path}")
129
- continue
130
-
131
- try:
132
- # Content is base64 encoded, with newlines.
133
- decoded_content = base64.b64decode(content_b64.replace("\n", ""))
134
- except (base64.binascii.Error, TypeError):
135
- print(f" [MCP] [Warning] Could not decode content for {item_path}")
136
- continue
137
-
138
- # Create file and parent directories in the temp location
139
- local_file_path = Path(temp_dir) / item_path
140
- local_file_path.parent.mkdir(parents=True, exist_ok=True)
141
-
142
- # Write the file content
143
- with open(local_file_path, "wb") as f:
144
- f.write(decoded_content)
145
-
146
- print(" [MCP] ✅ Repository download complete.")
147
- return temp_dir
148
-
149
- def __enter__(self):
150
- self._start_server()
151
- return self
152
-
153
- def __exit__(self, exc_type, exc_val, exc_tb):
154
- self._stop_server()
1
+ # src/xenfra/mcp_client.py
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ class MCPClient:
12
+ """
13
+ A client for communicating with a local github-mcp-server process.
14
+
15
+ This client starts the MCP server as a subprocess and interacts with it
16
+ over stdin and stdout to download a full repository to a temporary directory.
17
+ """
18
+
19
+ def __init__(self, mcp_server_path="github-mcp-server"):
20
+ """
21
+ Initializes the MCPClient.
22
+
23
+ Args:
24
+ mcp_server_path (str): The path to the github-mcp-server executable.
25
+ Assumes it's in the system's PATH by default.
26
+ """
27
+ self.mcp_server_path = mcp_server_path
28
+ self.process = None
29
+
30
+ def _start_server(self):
31
+ """Starts the github-mcp-server subprocess."""
32
+ if self.process and self.process.poll() is None:
33
+ return
34
+ try:
35
+ self.process = subprocess.Popen(
36
+ [self.mcp_server_path],
37
+ stdin=subprocess.PIPE,
38
+ stdout=subprocess.PIPE,
39
+ stderr=subprocess.PIPE,
40
+ text=True,
41
+ env=os.environ,
42
+ )
43
+ except FileNotFoundError:
44
+ raise RuntimeError(
45
+ f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH."
46
+ )
47
+ except Exception as e:
48
+ raise RuntimeError(f"Failed to start MCP server: {e}")
49
+
50
+ def _stop_server(self):
51
+ """Stops the MCP server process."""
52
+ if self.process:
53
+ self.process.terminate()
54
+ self.process.wait()
55
+ self.process = None
56
+
57
+ def _send_request(self, method: str, params: dict) -> dict:
58
+ """Sends a JSON-RPC request and returns the response."""
59
+ if not self.process or self.process.poll() is not None:
60
+ self._start_server()
61
+
62
+ request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
63
+
64
+ try:
65
+ self.process.stdin.write(json.dumps(request) + "\n")
66
+ self.process.stdin.flush()
67
+ response_line = self.process.stdout.readline()
68
+ if not response_line:
69
+ error_output = self.process.stderr.read()
70
+ raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
71
+
72
+ response = json.loads(response_line)
73
+ if "error" in response:
74
+ raise RuntimeError(f"MCP server returned an error: {response['error']}")
75
+ return response.get("result", {})
76
+ except (BrokenPipeError, json.JSONDecodeError) as e:
77
+ raise RuntimeError(f"Failed to communicate with MCP server: {e}")
78
+
79
+ def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
80
+ """
81
+ Downloads an entire repository at a specific commit to a local temporary directory.
82
+
83
+ Args:
84
+ repo_url (str): The full URL of the GitHub repository.
85
+ commit_sha (str): The commit SHA to download. Defaults to "HEAD".
86
+
87
+ Returns:
88
+ The path to the temporary directory containing the downloaded code.
89
+ """
90
+ try:
91
+ parts = repo_url.strip("/").split("/")
92
+ owner = parts[-2]
93
+ repo_name = parts[-1].replace(".git", "")
94
+ except IndexError:
95
+ raise ValueError(
96
+ "Invalid repository URL format. Expected format: https://github.com/owner/repo"
97
+ )
98
+
99
+ print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
100
+ tree_result = self._send_request(
101
+ method="git.get_repository_tree",
102
+ params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True},
103
+ )
104
+
105
+ tree = tree_result.get("tree", [])
106
+ if not tree:
107
+ raise RuntimeError("Could not retrieve repository file tree.")
108
+
109
+ temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
110
+ print(f" [MCP] Downloading to temporary directory: {temp_dir}")
111
+
112
+ for item in tree:
113
+ item_path = item.get("path")
114
+ item_type = item.get("type")
115
+
116
+ if not item_path or item_type != "blob": # Only handle files
117
+ continue
118
+
119
+ # For downloading content, we can use the commit_sha in the 'ref' parameter
120
+ # to ensure we get the content from the correct version.
121
+ content_result = self._send_request(
122
+ method="repos.get_file_contents",
123
+ params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha},
124
+ )
125
+
126
+ content_b64 = content_result.get("content")
127
+ if content_b64 is None:
128
+ print(f" [MCP] [Warning] Could not get content for {item_path}")
129
+ continue
130
+
131
+ try:
132
+ # Content is base64 encoded, with newlines.
133
+ decoded_content = base64.b64decode(content_b64.replace("\n", ""))
134
+ except (base64.binascii.Error, TypeError):
135
+ print(f" [MCP] [Warning] Could not decode content for {item_path}")
136
+ continue
137
+
138
+ # Create file and parent directories in the temp location
139
+ local_file_path = Path(temp_dir) / item_path
140
+ local_file_path.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ # Write the file content
143
+ with open(local_file_path, "wb") as f:
144
+ f.write(decoded_content)
145
+
146
+ print(" [MCP] ✅ Repository download complete.")
147
+ return temp_dir
148
+
149
+ def __enter__(self):
150
+ self._start_server()
151
+ return self
152
+
153
+ def __exit__(self, exc_type, exc_val, exc_tb):
154
+ self._stop_server()