Initial upload with full project structure
This commit is contained in:
276
app/src/confgen/editor.py
Normal file
276
app/src/confgen/editor.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""Interactive TUI editor using prompt_toolkit."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def console_print(text: str) -> None:
|
||||||
|
"""Print text to console."""
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
console.print(text)
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(message: str) -> bool:
|
||||||
|
"""Show a confirmation prompt."""
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
|
||||||
|
session = PromptSession()
|
||||||
|
result = session.prompt(f"{message} [y/N]: ")
|
||||||
|
return result.lower() in ("y", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_for_var(var_name: str, current_value: str) -> str:
|
||||||
|
"""Get the prompt text for a variable."""
|
||||||
|
if is_secret_variable(var_name):
|
||||||
|
return f"[secret] {var_name}"
|
||||||
|
elif is_boolean_variable(var_name, current_value):
|
||||||
|
return f"[boolean] {var_name} (yes/no)"
|
||||||
|
elif is_numeric_variable(var_name, current_value):
|
||||||
|
return f"[number] {var_name}"
|
||||||
|
else:
|
||||||
|
return var_name
|
||||||
|
|
||||||
|
|
||||||
|
def is_secret_variable(var_name: str) -> bool:
|
||||||
|
"""Check if a variable name suggests a secret."""
|
||||||
|
secret_patterns = [
|
||||||
|
"SECRET",
|
||||||
|
"PASSWORD",
|
||||||
|
"API_KEY",
|
||||||
|
"API_SECRET",
|
||||||
|
"TOKEN",
|
||||||
|
"CREDENTIAL",
|
||||||
|
"PRIVATE_KEY",
|
||||||
|
"AUTH",
|
||||||
|
"DB_PASSWORD",
|
||||||
|
]
|
||||||
|
return any(pattern in var_name.upper() for pattern in secret_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def is_boolean_variable(var_name: str, current_value: str) -> bool:
|
||||||
|
"""Check if a variable is likely a boolean."""
|
||||||
|
bool_names = ["debug", "enabled", "disabled", "verbose", "skip", "force"]
|
||||||
|
if var_name.lower() in bool_names:
|
||||||
|
return True
|
||||||
|
if current_value and current_value.lower() in ("true", "false", "yes", "no", "0", "1"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_numeric_variable(var_name: str, current_value: str) -> bool:
|
||||||
|
"""Check if a variable is likely numeric."""
|
||||||
|
num_names = ["port", "timeout", "retries", "count", "size", "limit"]
|
||||||
|
if var_name.lower() in num_names:
|
||||||
|
return True
|
||||||
|
if "_port" in var_name.lower() or var_name.lower().endswith("port"):
|
||||||
|
return True
|
||||||
|
if current_value:
|
||||||
|
try:
|
||||||
|
float(current_value)
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveEditor:
|
||||||
|
"""Interactive terminal UI for editing config values."""
|
||||||
|
|
||||||
|
def __init__(self, core, template_name: str, environment: str):
|
||||||
|
self.core = core
|
||||||
|
self.template_name = template_name
|
||||||
|
self.environment = environment
|
||||||
|
self.variables = {}
|
||||||
|
self._load_template_info()
|
||||||
|
|
||||||
|
def _load_template_info(self) -> None:
|
||||||
|
"""Load template and environment information."""
|
||||||
|
tmpl = self.core.get_template(self.template_name)
|
||||||
|
if not tmpl:
|
||||||
|
raise ValueError(f"Template '{self.template_name}' not found")
|
||||||
|
|
||||||
|
env_config = self.core.get_environment(self.environment)
|
||||||
|
if env_config:
|
||||||
|
self.variables = env_config.variables.copy()
|
||||||
|
|
||||||
|
from .template import TemplateEngine
|
||||||
|
|
||||||
|
engine = TemplateEngine()
|
||||||
|
if tmpl.path.exists():
|
||||||
|
content = tmpl.path.read_text()
|
||||||
|
extracted = engine.extract_variables(content)
|
||||||
|
|
||||||
|
for var in extracted:
|
||||||
|
if var not in self.variables:
|
||||||
|
self.variables[var] = ""
|
||||||
|
|
||||||
|
def run(self) -> Optional[str]:
|
||||||
|
"""Run the interactive editor."""
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
|
||||||
|
session = PromptSession()
|
||||||
|
|
||||||
|
console_print("\n[bold]Interactive Configuration Editor[/bold]")
|
||||||
|
console_print(f"Template: {self.template_name}")
|
||||||
|
console_print(f"Environment: {self.environment}\n")
|
||||||
|
|
||||||
|
for var_name, current_value in self.variables.items():
|
||||||
|
prompt = get_prompt_for_var(var_name, current_value)
|
||||||
|
|
||||||
|
if is_secret_variable(var_name):
|
||||||
|
value = session.prompt(
|
||||||
|
f"{prompt}: ",
|
||||||
|
is_password=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
value = session.prompt(
|
||||||
|
f"{prompt}: ",
|
||||||
|
default=str(current_value) if current_value else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if value:
|
||||||
|
self.variables[var_name] = value
|
||||||
|
|
||||||
|
console_print("\n[bold]Generated Configuration:[/bold]\n")
|
||||||
|
|
||||||
|
from .template import TemplateEngine
|
||||||
|
from .parsers import ConfigParser
|
||||||
|
|
||||||
|
engine = TemplateEngine()
|
||||||
|
tmpl = self.core.get_template(self.template_name)
|
||||||
|
|
||||||
|
if tmpl and tmpl.path.exists():
|
||||||
|
content = tmpl.path.read_text()
|
||||||
|
rendered = engine.render(content, self.variables)
|
||||||
|
|
||||||
|
parser = ConfigParser()
|
||||||
|
parsed = parser.parse(rendered)
|
||||||
|
|
||||||
|
from .formatter import OutputFormatter
|
||||||
|
|
||||||
|
formatter = OutputFormatter()
|
||||||
|
formatted = formatter.format_dict(parsed)
|
||||||
|
|
||||||
|
console_print(formatted)
|
||||||
|
|
||||||
|
if confirm("\nSave this configuration?"):
|
||||||
|
return parser.to_yaml(parsed)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigEditor:
|
||||||
|
"""Advanced config editor with type validation."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.values = {}
|
||||||
|
|
||||||
|
def prompt_for_value(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
current_value: Any,
|
||||||
|
vtype: str = "string",
|
||||||
|
required: bool = False,
|
||||||
|
choices: Optional[list] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Prompt for a value with type validation."""
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.validation import ValidationError, Validator
|
||||||
|
|
||||||
|
session = PromptSession()
|
||||||
|
|
||||||
|
class TypeValidator(Validator):
|
||||||
|
def validate(self, document):
|
||||||
|
text = document.text
|
||||||
|
if required and not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
if vtype == "integer" and text and not text.isdigit():
|
||||||
|
raise ValidationError(message="Must be a valid integer")
|
||||||
|
|
||||||
|
if vtype == "float" and text:
|
||||||
|
try:
|
||||||
|
float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError(message="Must be a valid number")
|
||||||
|
|
||||||
|
if vtype == "boolean" and text and text.lower() not in (
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"yes",
|
||||||
|
"no",
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
message="Must be true/false or yes/no"
|
||||||
|
)
|
||||||
|
|
||||||
|
if vtype == "port" and text:
|
||||||
|
if not text.isdigit() or not (1 <= int(text) <= 65535):
|
||||||
|
raise ValidationError(message="Port must be 1-65535")
|
||||||
|
|
||||||
|
if choices and text and text not in choices:
|
||||||
|
raise ValidationError(
|
||||||
|
message=f"Must be one of: {', '.join(choices)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt_text = get_prompt_for_var(name, str(current_value) if current_value else "")
|
||||||
|
default = str(current_value) if current_value else ""
|
||||||
|
|
||||||
|
validator = TypeValidator()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
value = session.prompt(
|
||||||
|
f"{prompt_text}: ",
|
||||||
|
default=default,
|
||||||
|
validator=validator,
|
||||||
|
complete_while_typing=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if vtype == "boolean":
|
||||||
|
return value.lower() in ("true", "yes", "1")
|
||||||
|
elif vtype == "integer":
|
||||||
|
return int(value) if value else 0
|
||||||
|
elif vtype == "float":
|
||||||
|
return float(value) if value else 0.0
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def edit_config(
|
||||||
|
self,
|
||||||
|
config: dict[str, Any],
|
||||||
|
schema: Optional[dict] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Edit a configuration dictionary."""
|
||||||
|
for key, value in config.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
console_print(f"\n[bold]{key}:[/bold]")
|
||||||
|
schema_props = schema.get("properties", {}) if schema else {}
|
||||||
|
config[key] = self.edit_config(value, schema_props.get(key))
|
||||||
|
else:
|
||||||
|
vtype = self._get_type_from_schema(key, schema) if schema else "string"
|
||||||
|
config[key] = self.prompt_for_value(key, value, vtype)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _get_type_from_schema(self, key: str, schema: Optional[dict]) -> str:
|
||||||
|
"""Get the expected type from a JSON schema."""
|
||||||
|
if not schema or "properties" not in schema:
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
prop = schema["properties"].get(key, {})
|
||||||
|
prop_type = prop.get("type", "string")
|
||||||
|
|
||||||
|
if prop_type == "integer":
|
||||||
|
return "integer"
|
||||||
|
elif prop_type == "number":
|
||||||
|
return "float"
|
||||||
|
elif prop_type == "boolean":
|
||||||
|
return "boolean"
|
||||||
|
elif prop_type == "object":
|
||||||
|
return "string"
|
||||||
|
else:
|
||||||
|
return "string"
|
||||||
Reference in New Issue
Block a user