fips-agents-cli 0.1.0__py3-none-any.whl → 0.1.2__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,311 @@
1
+ """Generator utilities for rendering MCP component templates."""
2
+
3
+ import ast
4
+ import json
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import jinja2
10
+ import tomlkit
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ def get_project_info(project_root: Path) -> dict[str, Any]:
17
+ """
18
+ Extract project metadata from pyproject.toml.
19
+
20
+ Args:
21
+ project_root: Path to the project root directory
22
+
23
+ Returns:
24
+ dict: Project metadata including:
25
+ - name: Project name
26
+ - module_name: Module name (with underscores)
27
+ - version: Project version
28
+
29
+ Raises:
30
+ FileNotFoundError: If pyproject.toml doesn't exist
31
+ ValueError: If pyproject.toml is malformed
32
+
33
+ Example:
34
+ >>> info = get_project_info(Path("/path/to/project"))
35
+ >>> print(info["name"])
36
+ 'my-mcp-server'
37
+ """
38
+ pyproject_path = project_root / "pyproject.toml"
39
+
40
+ if not pyproject_path.exists():
41
+ raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
42
+
43
+ try:
44
+ with open(pyproject_path) as f:
45
+ pyproject = tomlkit.parse(f.read())
46
+
47
+ project_name = pyproject.get("project", {}).get("name", "unknown")
48
+ project_version = pyproject.get("project", {}).get("version", "0.1.0")
49
+ module_name = project_name.replace("-", "_")
50
+
51
+ return {
52
+ "name": project_name,
53
+ "module_name": module_name,
54
+ "version": project_version,
55
+ }
56
+
57
+ except Exception as e:
58
+ raise ValueError(f"Failed to parse pyproject.toml: {e}") from e
59
+
60
+
61
+ def load_template(project_root: Path, component_type: str, template_name: str) -> jinja2.Template:
62
+ """
63
+ Load a Jinja2 template from the project's generator templates.
64
+
65
+ Args:
66
+ project_root: Path to the project root directory
67
+ component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
68
+ template_name: Name of the template file (e.g., 'component.py.j2')
69
+
70
+ Returns:
71
+ jinja2.Template: Loaded Jinja2 template
72
+
73
+ Raises:
74
+ FileNotFoundError: If template file doesn't exist
75
+ jinja2.TemplateError: If template is malformed
76
+
77
+ Example:
78
+ >>> template = load_template(root, "tool", "component.py.j2")
79
+ >>> rendered = template.render(component_name="my_tool")
80
+ """
81
+ template_path = (
82
+ project_root / ".fips-agents-cli" / "generators" / component_type / template_name
83
+ )
84
+
85
+ if not template_path.exists():
86
+ raise FileNotFoundError(f"Template not found: {template_path}")
87
+
88
+ try:
89
+ with open(template_path) as f:
90
+ template_content = f.read()
91
+
92
+ # Create a Jinja2 environment with the template directory as the loader path
93
+ template_dir = template_path.parent
94
+ env = jinja2.Environment(
95
+ loader=jinja2.FileSystemLoader(str(template_dir)),
96
+ trim_blocks=True,
97
+ lstrip_blocks=True,
98
+ )
99
+
100
+ return env.from_string(template_content)
101
+
102
+ except jinja2.TemplateError as e:
103
+ raise jinja2.TemplateError(f"Failed to load template {template_name}: {e}") from e
104
+
105
+
106
+ def load_params_file(params_path: Path) -> list[dict[str, Any]]:
107
+ """
108
+ Load and validate parameter definitions from a JSON file.
109
+
110
+ Expected schema:
111
+ [
112
+ {
113
+ "name": "query",
114
+ "type": "str",
115
+ "description": "Search query",
116
+ "required": true,
117
+ "min_length": 1,
118
+ "max_length": 100
119
+ }
120
+ ]
121
+
122
+ Args:
123
+ params_path: Path to the JSON parameter file
124
+
125
+ Returns:
126
+ list: List of parameter definition dictionaries
127
+
128
+ Raises:
129
+ FileNotFoundError: If params file doesn't exist
130
+ ValueError: If JSON is invalid or schema is incorrect
131
+
132
+ Example:
133
+ >>> params = load_params_file(Path("params.json"))
134
+ >>> print(params[0]["name"])
135
+ 'query'
136
+ """
137
+ if not params_path.exists():
138
+ raise FileNotFoundError(f"Parameters file not found: {params_path}")
139
+
140
+ try:
141
+ with open(params_path) as f:
142
+ params = json.load(f)
143
+
144
+ except json.JSONDecodeError as e:
145
+ raise ValueError(f"Invalid JSON in parameters file: {e}") from e
146
+
147
+ # Validate schema
148
+ if not isinstance(params, list):
149
+ raise ValueError("Parameters file must contain a JSON array of parameter definitions")
150
+
151
+ for i, param in enumerate(params):
152
+ if not isinstance(param, dict):
153
+ raise ValueError(f"Parameter {i} must be a JSON object")
154
+
155
+ # Check required fields
156
+ required_fields = ["name", "type", "description"]
157
+ for field in required_fields:
158
+ if field not in param:
159
+ raise ValueError(f"Parameter {i} missing required field: {field}")
160
+
161
+ # Validate name is a valid Python identifier
162
+ if not param["name"].isidentifier():
163
+ raise ValueError(f"Parameter {i} has invalid name: {param['name']}")
164
+
165
+ # Validate type
166
+ valid_types = [
167
+ "str",
168
+ "int",
169
+ "float",
170
+ "bool",
171
+ "list[str]",
172
+ "list[int]",
173
+ "list[float]",
174
+ "Optional[str]",
175
+ "Optional[int]",
176
+ "Optional[float]",
177
+ "Optional[bool]",
178
+ ]
179
+ if param["type"] not in valid_types:
180
+ raise ValueError(
181
+ f"Parameter {i} has invalid type: {param['type']}. "
182
+ f"Valid types: {', '.join(valid_types)}"
183
+ )
184
+
185
+ return params
186
+
187
+
188
+ def render_component(template: jinja2.Template, variables: dict[str, Any]) -> str:
189
+ """
190
+ Render a Jinja2 template with the provided variables.
191
+
192
+ Args:
193
+ template: Jinja2 template object
194
+ variables: Dictionary of template variables
195
+
196
+ Returns:
197
+ str: Rendered template as a string
198
+
199
+ Raises:
200
+ jinja2.TemplateError: If rendering fails
201
+
202
+ Example:
203
+ >>> template = load_template(root, "tool", "component.py.j2")
204
+ >>> code = render_component(template, {"component_name": "my_tool"})
205
+ """
206
+ try:
207
+ return template.render(**variables)
208
+ except jinja2.TemplateError as e:
209
+ raise jinja2.TemplateError(f"Failed to render template: {e}") from e
210
+
211
+
212
+ def validate_python_syntax(code: str) -> tuple[bool, str]:
213
+ """
214
+ Validate Python code syntax using ast.parse().
215
+
216
+ Args:
217
+ code: Python code as a string
218
+
219
+ Returns:
220
+ tuple: (is_valid, error_message)
221
+ is_valid is True if syntax is valid, False otherwise
222
+ error_message is empty string if valid, otherwise contains error description
223
+
224
+ Example:
225
+ >>> code = "def my_func():\\n return 42"
226
+ >>> is_valid, msg = validate_python_syntax(code)
227
+ >>> print(is_valid)
228
+ True
229
+ """
230
+ try:
231
+ ast.parse(code)
232
+ return True, ""
233
+ except SyntaxError as e:
234
+ return False, f"Syntax error at line {e.lineno}: {e.msg}"
235
+ except Exception as e:
236
+ return False, f"Failed to validate syntax: {e}"
237
+
238
+
239
+ def write_component_file(content: str, file_path: Path) -> None:
240
+ """
241
+ Write component content to a file, creating parent directories if needed.
242
+
243
+ Args:
244
+ content: File content as a string
245
+ file_path: Path where the file should be written
246
+
247
+ Raises:
248
+ OSError: If file cannot be written (permissions, etc.)
249
+
250
+ Example:
251
+ >>> write_component_file("print('hello')", Path("src/tools/my_tool.py"))
252
+ """
253
+ try:
254
+ # Create parent directories if they don't exist
255
+ file_path.parent.mkdir(parents=True, exist_ok=True)
256
+
257
+ # Write the file
258
+ with open(file_path, "w") as f:
259
+ f.write(content)
260
+
261
+ except OSError as e:
262
+ raise OSError(f"Failed to write file {file_path}: {e}") from e
263
+
264
+
265
+ def run_component_tests(project_root: Path, test_file: Path) -> tuple[bool, str]:
266
+ """
267
+ Run pytest on a generated test file and capture output.
268
+
269
+ Args:
270
+ project_root: Path to the project root directory
271
+ test_file: Path to the test file to run (relative or absolute)
272
+
273
+ Returns:
274
+ tuple: (success, output)
275
+ success is True if tests passed, False if failed
276
+ output is the pytest output as a string
277
+
278
+ Example:
279
+ >>> success, output = run_component_tests(root, Path("tests/tools/test_my_tool.py"))
280
+ >>> if success:
281
+ ... print("Tests passed!")
282
+ """
283
+ try:
284
+ # Make test_file relative to project_root if it's absolute
285
+ if test_file.is_absolute():
286
+ try:
287
+ test_file = test_file.relative_to(project_root)
288
+ except ValueError:
289
+ # test_file is not relative to project_root, use as-is
290
+ pass
291
+
292
+ # Run pytest with minimal output
293
+ result = subprocess.run(
294
+ ["pytest", str(test_file), "-v", "--tb=short"],
295
+ cwd=str(project_root),
296
+ capture_output=True,
297
+ text=True,
298
+ timeout=30,
299
+ )
300
+
301
+ output = result.stdout + result.stderr
302
+ success = result.returncode == 0
303
+
304
+ return success, output
305
+
306
+ except subprocess.TimeoutExpired:
307
+ return False, "Test execution timed out after 30 seconds"
308
+ except FileNotFoundError:
309
+ return False, "pytest not found. Install with: pip install pytest"
310
+ except Exception as e:
311
+ return False, f"Failed to run tests: {e}"
@@ -3,7 +3,6 @@
3
3
  import re
4
4
  import shutil
5
5
  from pathlib import Path
6
- from typing import Optional
7
6
 
8
7
  import tomlkit
9
8
  from rich.console import Console
@@ -11,7 +10,7 @@ from rich.console import Console
11
10
  console = Console()
12
11
 
13
12
 
14
- def validate_project_name(name: str) -> tuple[bool, Optional[str]]:
13
+ def validate_project_name(name: str) -> tuple[bool, str | None]:
15
14
  """
16
15
  Validate project name according to Python package naming conventions.
17
16
 
@@ -0,0 +1,183 @@
1
+ """Validation utilities for MCP component generation."""
2
+
3
+ import keyword
4
+ import re
5
+ from pathlib import Path
6
+
7
+ import tomlkit
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def find_project_root() -> Path | None:
14
+ """
15
+ Find the project root by walking up from current directory.
16
+
17
+ Looks for pyproject.toml with fastmcp dependency to identify MCP server projects.
18
+
19
+ Returns:
20
+ Path: Project root path if found
21
+ None: If no valid MCP project root is found
22
+
23
+ Example:
24
+ >>> root = find_project_root()
25
+ >>> if root:
26
+ ... print(f"Found project at {root}")
27
+ """
28
+ current_path = Path.cwd()
29
+
30
+ # Walk up the directory tree
31
+ for parent in [current_path] + list(current_path.parents):
32
+ pyproject_path = parent / "pyproject.toml"
33
+
34
+ if pyproject_path.exists():
35
+ try:
36
+ with open(pyproject_path) as f:
37
+ pyproject = tomlkit.parse(f.read())
38
+
39
+ # Check if this is an MCP server project
40
+ dependencies = pyproject.get("project", {}).get("dependencies", [])
41
+
42
+ # Check for fastmcp dependency
43
+ for dep in dependencies:
44
+ if isinstance(dep, str) and "fastmcp" in dep.lower():
45
+ return parent
46
+
47
+ except Exception as e:
48
+ console.print(f"[yellow]⚠[/yellow] Could not parse {pyproject_path}: {e}")
49
+ continue
50
+
51
+ return None
52
+
53
+
54
+ def is_valid_component_name(name: str) -> tuple[bool, str]:
55
+ """
56
+ Validate component name as a valid Python identifier.
57
+
58
+ Component names must:
59
+ - Be valid Python identifiers (snake_case)
60
+ - Not be Python keywords
61
+ - Not be empty
62
+ - Start with a letter or underscore
63
+ - Contain only letters, numbers, and underscores
64
+
65
+ Args:
66
+ name: The component name to validate
67
+
68
+ Returns:
69
+ tuple: (is_valid, error_message)
70
+ is_valid is True if valid, False otherwise
71
+ error_message is empty string if valid, otherwise contains error description
72
+
73
+ Examples:
74
+ >>> is_valid_component_name("my_tool")
75
+ (True, '')
76
+ >>> is_valid_component_name("123invalid")
77
+ (False, 'Component name must start with a letter or underscore')
78
+ """
79
+ if not name:
80
+ return False, "Component name cannot be empty"
81
+
82
+ # Check if it's a valid Python identifier
83
+ if not name.isidentifier():
84
+ if name[0].isdigit():
85
+ return False, "Component name must start with a letter or underscore"
86
+ return False, (
87
+ "Component name must be a valid Python identifier (use snake_case: "
88
+ "letters, numbers, underscores only)"
89
+ )
90
+
91
+ # Check if it's a Python keyword
92
+ if keyword.iskeyword(name):
93
+ return False, f"Component name '{name}' is a Python keyword and cannot be used"
94
+
95
+ # Recommend snake_case
96
+ if not re.match(r"^[a-z_][a-z0-9_]*$", name):
97
+ return False, (
98
+ "Component name should use snake_case (lowercase letters, numbers, " "underscores only)"
99
+ )
100
+
101
+ return True, ""
102
+
103
+
104
+ def component_exists(project_root: Path, component_type: str, name: str) -> bool:
105
+ """
106
+ Check if a component file already exists.
107
+
108
+ Args:
109
+ project_root: Path to the project root directory
110
+ component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
111
+ name: Component name (will check for {name}.py)
112
+
113
+ Returns:
114
+ bool: True if component file exists, False otherwise
115
+
116
+ Example:
117
+ >>> root = Path("/path/to/project")
118
+ >>> component_exists(root, "tool", "my_tool")
119
+ False
120
+ """
121
+ # Map component types to their directory locations
122
+ component_dirs = {
123
+ "tool": "tools",
124
+ "resource": "resources",
125
+ "prompt": "prompts",
126
+ "middleware": "middleware",
127
+ }
128
+
129
+ if component_type not in component_dirs:
130
+ return False
131
+
132
+ component_dir = component_dirs[component_type]
133
+ component_file = project_root / "src" / component_dir / f"{name}.py"
134
+
135
+ return component_file.exists()
136
+
137
+
138
+ def validate_generator_templates(project_root: Path, component_type: str) -> tuple[bool, str]:
139
+ """
140
+ Validate that generator templates exist for the component type.
141
+
142
+ Args:
143
+ project_root: Path to the project root directory
144
+ component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
145
+
146
+ Returns:
147
+ tuple: (is_valid, error_message)
148
+ is_valid is True if templates exist, False otherwise
149
+ error_message is empty string if valid, otherwise contains error description
150
+
151
+ Example:
152
+ >>> root = Path("/path/to/project")
153
+ >>> is_valid, msg = validate_generator_templates(root, "tool")
154
+ >>> if is_valid:
155
+ ... print("Templates found!")
156
+ """
157
+ generators_dir = project_root / ".fips-agents-cli" / "generators" / component_type
158
+
159
+ if not generators_dir.exists():
160
+ return False, (
161
+ f"Generator templates not found for '{component_type}'\n"
162
+ f"Expected: {generators_dir}\n"
163
+ "Was this project created with fips-agents create mcp-server?"
164
+ )
165
+
166
+ # Check for required template files
167
+ component_template = generators_dir / "component.py.j2"
168
+ test_template = generators_dir / "test.py.j2"
169
+
170
+ missing_files = []
171
+ if not component_template.exists():
172
+ missing_files.append("component.py.j2")
173
+ if not test_template.exists():
174
+ missing_files.append("test.py.j2")
175
+
176
+ if missing_files:
177
+ return False, (
178
+ f"Missing template files for '{component_type}':\n"
179
+ f" {', '.join(missing_files)}\n"
180
+ f"Expected location: {generators_dir}"
181
+ )
182
+
183
+ return True, ""
@@ -1,3 +1,3 @@
1
1
  """Version information for fips-agents-cli."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"