From 08c7cb225a2ba899986cb5c40339ff8264930b9c Mon Sep 17 00:00:00 2001 From: eyalz Date: Thu, 7 Aug 2025 18:15:25 +0300 Subject: [PATCH] Add basic interactive prompt mode to fill variables --- .gitignore | 2 +- commands/create_spec.py | 34 ++++++++++++++--------- cxk.py | 22 +++++---------- engine/__init__.py | 1 + prompt/__init__.py | 10 +++++++ pyproject.toml | 1 + test_runner.py | 25 +++++++++++++++++ tests/e2e/test_e2e.py | 58 +++++++++++++++++++++++++++------------- tests/templates/spec1.md | 4 ++- 9 files changed, 108 insertions(+), 49 deletions(-) create mode 100644 prompt/__init__.py create mode 100644 test_runner.py diff --git a/.gitignore b/.gitignore index 953faa0..42d959a 100644 --- a/.gitignore +++ b/.gitignore @@ -168,7 +168,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ diff --git a/commands/create_spec.py b/commands/create_spec.py index 24aae02..34e05f5 100644 --- a/commands/create_spec.py +++ b/commands/create_spec.py @@ -1,16 +1,9 @@ - +import json import os import sys from engine import TemplateEngine, TemplateParseError - - -# Create spec command: -# cxk create-spec [spec-template] -# Will output into stdout the rendered spec file -# -# For now all it does is to use TemplateEngine to print the list of variables in the template and nothing more. -# It also prints an error if the template is not found or cannot be parsed. +from prompt import collect_var_value async def handle_create_spec(spec_template: str): @@ -31,14 +24,31 @@ async def handle_create_spec(spec_template: str): # Get variables from template variables = template_engine.get_variables() - # Print list of variables + # Collect values for each variable + collected_vars = {} if variables: - print("Template variables:") + print("Collecting values for template variables:") for var in sorted(variables): - print(f" - {var}") + raw_value = await collect_var_value(var) + print(f" {var}: {raw_value}") + + # Try to parse as JSON if it looks like JSON + if raw_value and (raw_value.strip().startswith('{') or raw_value.strip().startswith('[')): + try: + collected_vars[var] = json.loads(raw_value) + except json.JSONDecodeError: + # If it's not valid JSON, use as string + collected_vars[var] = raw_value + else: + collected_vars[var] = raw_value else: print("No variables found in template") + # Render the template with collected variables + rendered_content = await template_engine.render_async(**collected_vars) + print("\nRendered template:") + print(rendered_content) + except TemplateParseError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/cxk.py b/cxk.py index 63d32f2..f6c144c 100644 --- a/cxk.py +++ b/cxk.py @@ -2,8 +2,8 @@ import asyncio import sys -from commands.init import handle_init from commands.create_spec import handle_create_spec +from commands.init import handle_init from commands.mcp import ( MCPAddHttpContext, MCPAddSSEContext, @@ -35,16 +35,10 @@ async def main(): add_sse_parser.add_argument("url", help="URL of the SSE server") # cxk mcp add-stdio [server-name] --env [env-var] -- [command] - add_stdio_parser = mcp_subparsers.add_parser( - "add-stdio", help="Add stdio MCP server" - ) + add_stdio_parser = mcp_subparsers.add_parser("add-stdio", help="Add stdio MCP server") add_stdio_parser.add_argument("server_name", help="Name of the server") - add_stdio_parser.add_argument( - "--env", action="append", help="Environment variable (key=value)" - ) - add_stdio_parser.add_argument( - "command_line", nargs=argparse.ONE_OR_MORE, help="Command to run" - ) + add_stdio_parser.add_argument("--env", action="append", help="Environment variable (key=value)") + add_stdio_parser.add_argument("command_line", nargs=argparse.ONE_OR_MORE, help="Command to run") # cxk mcp add-http [server-name] [url] add_http_parser = mcp_subparsers.add_parser("add-http", help="Add HTTP MCP server") @@ -74,9 +68,7 @@ async def main(): if args.mcp_command == "add-sse": mcp_context = MCPCommandContext( subcommand="add-sse", - add_sse=MCPAddSSEContext( - server_name=args.server_name, url=args.url - ), + add_sse=MCPAddSSEContext(server_name=args.server_name, url=args.url), ) await handle_mcp(state, mcp_context) @@ -94,9 +86,7 @@ async def main(): elif args.mcp_command == "add-http": mcp_context = MCPCommandContext( subcommand="add-http", - add_http=MCPAddHttpContext( - server_name=args.server_name, url=args.url - ), + add_http=MCPAddHttpContext(server_name=args.server_name, url=args.url), ) await handle_mcp(state, mcp_context) diff --git a/engine/__init__.py b/engine/__init__.py index e109f9f..88aa50f 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -18,6 +18,7 @@ def __init__(self, template_path: str): self.env = Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(), + enable_async=True, ) self.template = self.env.get_template(template_name) self.template_path = template_path diff --git a/prompt/__init__.py b/prompt/__init__.py new file mode 100644 index 0000000..f937680 --- /dev/null +++ b/prompt/__init__.py @@ -0,0 +1,10 @@ +import questionary + +# Add interactive prompt helpers using questionary that will collect values for +# unspecified template variables. + + +async def collect_var_value(var_name: str) -> str: + """Collect a value for a variable using questionary.""" + + return await questionary.text(f"Please provide a value for '{var_name}':").ask_async() diff --git a/pyproject.toml b/pyproject.toml index 72fa73e..5b306cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "jinja2>=3.1.6", "pydantic>=2.11.7", + "questionary>=2.1.0", ] [project.optional-dependencies] diff --git a/test_runner.py b/test_runner.py new file mode 100644 index 0000000..8308bae --- /dev/null +++ b/test_runner.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Test runner script that patches collect_var_value for e2e testing.""" +import asyncio +from unittest.mock import patch + +from cxk import main + + +async def mock_collect_var_value(var_name: str) -> str: + """Mock implementation that returns predictable values based on variable name.""" + mock_values = { + "name": "John", + "age": "25", + "city": "New York", + "weather": '{"condition": "sunny", "temp": "75F"}', + "username": "testuser", + "user": "test_user" + } + return mock_values.get(var_name, f"mock_value_{var_name}") + + +if __name__ == "__main__": + # Patch collect_var_value before running main + with patch('commands.create_spec.collect_var_value', side_effect=mock_collect_var_value): + asyncio.run(main()) \ No newline at end of file diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 4d4c05c..8756af1 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -41,10 +41,15 @@ def temp_non_git_dir(self): with tempfile.TemporaryDirectory() as temp_dir: yield Path(temp_dir) - def run_cli(self, args, cwd=None): + def run_cli(self, args, cwd=None, use_test_runner=False): """Run the CLI and return result.""" - # Get the path to cxk.py relative to this test file - cli_path = Path(__file__).parent.parent.parent / "cxk.py" + if use_test_runner: + # Use test runner that patches collect_var_value + cli_path = Path(__file__).parent.parent.parent / "test_runner.py" + else: + # Get the path to cxk.py relative to this test file + cli_path = Path(__file__).parent.parent.parent / "cxk.py" + cmd = ["python", str(cli_path)] + args result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) @@ -321,15 +326,22 @@ def test_create_spec_with_variables(self, temp_non_git_dir): template_file = temp_non_git_dir / "test_template.j2" template_file.write_text(template_content) - # Run create-spec command - result = self.run_cli(["create-spec", str(template_file)]) + # Run create-spec command with test runner to patch collect_var_value + result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True) assert result.returncode == 0 - assert "Template variables:" in result.stdout - assert "- age" in result.stdout - assert "- city" in result.stdout - assert "- name" in result.stdout - assert "- weather" in result.stdout + assert "Collecting values for template variables:" in result.stdout + # Verify mocked values are displayed + assert "age: 25" in result.stdout + assert "city: New York" in result.stdout + assert "name: John" in result.stdout + assert 'weather: {"condition": "sunny", "temp": "75F"}' in result.stdout + + # Verify rendered template output + assert "Rendered template:" in result.stdout + assert "Hello John!" in result.stdout + assert "Your age is 25 and you live in New York." in result.stdout + assert "Today's weather is sunny with temperature 75F." in result.stdout def test_create_spec_no_variables(self, temp_non_git_dir): """Test create-spec with a template containing no variables.""" @@ -338,12 +350,16 @@ def test_create_spec_no_variables(self, temp_non_git_dir): template_file = temp_non_git_dir / "static_template.j2" template_file.write_text(template_content) - # Run create-spec command - result = self.run_cli(["create-spec", str(template_file)]) + # Run create-spec command with test runner (patching won't affect this case) + result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True) assert result.returncode == 0 assert "No variables found in template" in result.stdout + # Verify rendered template output for static template + assert "Rendered template:" in result.stdout + assert "This is a static template with no variables." in result.stdout + def test_create_spec_relative_path(self, temp_non_git_dir): """Test create-spec with a relative path (filename only).""" # Create a test template @@ -351,16 +367,20 @@ def test_create_spec_relative_path(self, temp_non_git_dir): template_file = temp_non_git_dir / "relative_template.j2" template_file.write_text(template_content) - # Run create-spec command with just the filename (relative path) - result = self.run_cli(["create-spec", "relative_template.j2"], cwd=temp_non_git_dir) + # Run create-spec command with just the filename (relative path) using test runner + result = self.run_cli(["create-spec", "relative_template.j2"], cwd=temp_non_git_dir, use_test_runner=True) assert result.returncode == 0 - assert "Template variables:" in result.stdout - assert "- username" in result.stdout + assert "Collecting values for template variables:" in result.stdout + assert "username: testuser" in result.stdout + + # Verify rendered template output + assert "Rendered template:" in result.stdout + assert "Hello testuser!" in result.stdout def test_create_spec_file_not_found(self, temp_non_git_dir): """Test create-spec with non-existent template file.""" - result = self.run_cli(["create-spec", "non_existent.j2"], cwd=temp_non_git_dir) + result = self.run_cli(["create-spec", "non_existent.j2"], cwd=temp_non_git_dir, use_test_runner=True) assert result.returncode != 0 assert "Error: Template file 'non_existent.j2' not found" in result.stderr @@ -372,7 +392,7 @@ def test_create_spec_invalid_template(self, temp_non_git_dir): template_file = temp_non_git_dir / "invalid_template.j2" template_file.write_text(template_content) - # Run create-spec command - result = self.run_cli(["create-spec", str(template_file)]) + # Run create-spec command with test runner + result = self.run_cli(["create-spec", str(template_file)], use_test_runner=True) assert result.returncode != 0 diff --git a/tests/templates/spec1.md b/tests/templates/spec1.md index 862424f..e1fc0ce 100644 --- a/tests/templates/spec1.md +++ b/tests/templates/spec1.md @@ -2,7 +2,9 @@ ## Ticket description -{{ ticket }} +{{ ticket.id }} + +{{ ticket.description }} ## Additional context