diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9e9b8c..f4d6131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,13 +35,13 @@ jobs: run: pip install -e ".[dev]" - name: Lint (ruff) - run: ruff check src/pytypeform + run: ruff check src/typeform - name: Type-check (mypy) - run: mypy src/pytypeform + run: mypy src/typeform - name: Test (pytest + coverage) - run: pytest --cov=pytypeform --cov-report=xml --cov-report=term-missing + run: pytest --cov=typeform --cov-report=xml --cov-report=term-missing - name: Upload coverage if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' diff --git a/README.md b/README.md index 5a56ae8..9c3586e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A Type-Safe UI/CLI Generator powered by Pydantic and Prompt-Toolkit. -[![PyPI version](https://img.shields.io/pypi/v/pytypeform.svg)](https://pypi.org/project/pytypeform/) -[![Python](https://img.shields.io/pypi/pyversions/pytypeform.svg)](https://pypi.org/project/pytypeform/) -[![CI](https://github.com/sthitaprajnas/pytypeform/actions/workflows/ci.yml/badge.svg)](https://github.com/sthitaprajnas/pytypeform/actions/workflows/ci.yml) +[![PyPI version](https://img.shields.io/pypi/v/typeform.svg)](https://pypi.org/project/typeform/) +[![Python](https://img.shields.io/pypi/pyversions/typeform.svg)](https://pypi.org/project/typeform/) +[![CI](https://github.com/sthitaprajnas/typeform/actions/workflows/ci.yml/badge.svg)](https://github.com/sthitaprajnas/typeform/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-green)](LICENSE) -[![Typed](https://img.shields.io/badge/typing-py.typed-informational)](src/pytypeform/py.typed) +[![Typed](https://img.shields.io/badge/typing-py.typed-informational)](src/typeform/py.typed) Typerform transforms your Pydantic models into professional, interactive CLI wizards. Stop writing boilerplate input loops and manual validation—let your schemas drive the user experience. @@ -26,7 +26,7 @@ Typerform transforms your Pydantic models into professional, interactive CLI wiz ## Installation ```bash -pip install pytypeform +pip install typeform ``` ## Quick Start @@ -34,7 +34,7 @@ pip install pytypeform ```python from typing import Literal from pydantic import BaseModel, Field -from pytypeform import form +from typeform import form class SetupConfig(BaseModel): project_name: str = Field(description="Project Name", min_length=3) @@ -90,12 +90,12 @@ config = form(MyModel, hydrate_from=[os.environ]) Contributions are welcome! Whether it's bug reports, feature requests, or new prompt engines. ```bash -git clone https://github.com/sthitaprajnas/pytypeform.git -cd pytypeform +git clone https://github.com/sthitaprajnas/typeform.git +cd typeform pip install -e ".[dev]" pytest # run test suite -ruff check src/pytypeform # lint -mypy src/pytypeform # type-check +ruff check src/typeform # lint +mypy src/typeform # type-check ``` ## License diff --git a/examples/collection_wizard.py b/examples/collection_wizard.py index c7a115d..09186c2 100644 --- a/examples/collection_wizard.py +++ b/examples/collection_wizard.py @@ -5,7 +5,7 @@ # Add src to sys.path sys.path.insert(0, "./src") -from pytypeform import form +from typeform import form from rich.console import Console console = Console() diff --git a/examples/enterprise_wizard.py b/examples/enterprise_wizard.py index 36281a0..ce0d6f3 100644 --- a/examples/enterprise_wizard.py +++ b/examples/enterprise_wizard.py @@ -5,7 +5,7 @@ # Add the src directory to sys.path sys.path.insert(0, "./src") -from pytypeform import form +from typeform import form from rich.console import Console console = Console() diff --git a/examples/simple_wizard.py b/examples/simple_wizard.py index be1ba55..d021d94 100644 --- a/examples/simple_wizard.py +++ b/examples/simple_wizard.py @@ -3,12 +3,12 @@ from pydantic import BaseModel, EmailStr, Field -# Add the src directory to sys.path so we can import pytypeform without installing it +# Add the src directory to sys.path so we can import typeform without installing it sys.path.insert(0, "./src") from rich.console import Console -from pytypeform import form +from typeform import form console = Console() diff --git a/examples/ultimate_demo.py b/examples/ultimate_demo.py index 4a2139e..881b98b 100644 --- a/examples/ultimate_demo.py +++ b/examples/ultimate_demo.py @@ -6,7 +6,7 @@ # Add src to sys.path sys.path.insert(0, "./src") -from pytypeform import form +from typeform import form from rich.console import Console console = Console() diff --git a/examples/ultimate_wizard.py b/examples/ultimate_wizard.py index fe402fb..1c0f21d 100644 --- a/examples/ultimate_wizard.py +++ b/examples/ultimate_wizard.py @@ -6,7 +6,7 @@ # Add src to sys.path sys.path.insert(0, "./src") -from pytypeform import form +from typeform import form from rich.console import Console console = Console() diff --git a/pyproject.toml b/pyproject.toml index 532dbd4..ed5e830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dev = [ # --------------------------------------------------------------------------- [tool.hatch.build.targets.wheel] -packages = ["src/pytypeform"] +packages = ["src/typeform"] [tool.hatch.build.targets.sdist] include = [ @@ -89,7 +89,7 @@ addopts = "-v --tb=short" [tool.mypy] strict = true python_version = "3.10" -files = ["src/pytypeform"] +files = ["src/typeform"] [tool.ruff] line-length = 100 @@ -100,7 +100,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"] ignore = ["E501"] [tool.coverage.run] -source = ["src/pytypeform"] +source = ["src/typeform"] [tool.coverage.report] show_missing = true diff --git a/src/typeform/__init__.py b/src/typeform/__init__.py new file mode 100644 index 0000000..068a141 --- /dev/null +++ b/src/typeform/__init__.py @@ -0,0 +1,4 @@ +from .core import form + +__version__ = "0.1.0" +__all__ = ["form"] diff --git a/src/typeform/core.py b/src/typeform/core.py new file mode 100644 index 0000000..20397df --- /dev/null +++ b/src/typeform/core.py @@ -0,0 +1,280 @@ +import typing +from collections.abc import Sequence +from enum import Enum +from typing import Any, TypeVar, get_args, get_origin + +from prompt_toolkit import prompt as pt_prompt +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.shortcuts import confirm as pt_confirm +from pydantic import BaseModel, SecretStr, ValidationError, create_model +from pydantic.fields import FieldInfo +from rich.console import Console +from rich.panel import Panel + +T = TypeVar("T", bound=BaseModel) + + +class PromptEngine: + """Pluggable engine for handling user prompts using prompt_toolkit.""" + + def ask_text(self, message: str, default: str = "", password: bool = False) -> str: + return pt_prompt(f"{message}: ", default=default, is_password=password) + + def ask_confirm(self, message: str, default: bool = False) -> bool: + return pt_confirm(f"{message}") + + def ask_choice(self, message: str, choices: list[str], default: str = "") -> str: + msg = f"{message} ({'/'.join(choices)})" + completer = WordCompleter(choices, ignore_case=True) + return pt_prompt(f"{msg}: ", default=str(default), completer=completer) + + +class TyperformConfig: + def __init__(self) -> None: + self.console = Console(force_terminal=False) + self.engine = PromptEngine() + self.verbose = True + self.show_progress = True + + +config = TyperformConfig() + + +def set_engine(engine: PromptEngine) -> None: + config.engine = engine + + +def _is_list_type(annotation: Any) -> bool: + origin = get_origin(annotation) + return origin in (list, list, Sequence, list) + + +def _is_optional_type(annotation: Any) -> bool: + origin = get_origin(annotation) + if origin is typing.Union or origin is getattr(typing, "UnionType", type(None)): + args = get_args(annotation) + return type(None) in args + return False + + +def _extract_base_type(annotation: Any) -> Any: + origin = get_origin(annotation) + if origin is typing.Union or origin is getattr(typing, "UnionType", type(None)): + args = get_args(annotation) + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1: + return non_none_args[0] + return annotation + + +def _get_prompt_class_and_choices(annotation: Any) -> tuple[type | None, list[str] | None]: + base_type = _extract_base_type(annotation) + origin = get_origin(base_type) + args = get_args(base_type) + if base_type is bool: + return bool, None + if origin is typing.Literal or (isinstance(base_type, type) and issubclass(base_type, Enum)): + choices = ( + [str(arg) for arg in args] if origin is typing.Literal else [e.name for e in base_type] + ) + return base_type, choices + return None, None + + +def _should_show_field(field_info: FieldInfo, current_data: dict[str, Any]) -> bool: + if not field_info.json_schema_extra or "when" not in field_info.json_schema_extra: # type: ignore + return True + + when_condition = field_info.json_schema_extra["when"] # type: ignore + if isinstance(when_condition, str): + try: + return bool(eval(when_condition, {}, current_data)) + except Exception: + return True + return True + + +def _prompt_for_field( + field_name: str, + field_info: FieldInfo, + current_data: dict[str, Any], + current_step: int = 0, + total_steps: int = 0, +) -> Any: + prompt_text = field_info.description or field_name.replace("_", " ").title() + is_required = field_info.is_required() + + # DYNAMIC DEFAULTING + default = field_info.default if not is_required else None + if callable(default): + try: + default = default(current_data) + except Exception: + default = None + + base_type = _extract_base_type(field_info.annotation) + + # 1. Handle Lists (Collection Wizard) + if _is_list_type(base_type): + item_type = get_args(base_type)[0] + items: list[Any] = [] + config.console.print( + f"\n[bold blue]Entering list collection for: {prompt_text}[/bold blue]" + ) + while True: + item_field_info = FieldInfo( + annotation=item_type, description=f"{prompt_text} Item #{len(items) + 1}" + ) + if items and not config.engine.ask_confirm(f"Add another item to '{prompt_text}'?"): + break + + items_context = {**current_data, "_items": items} + val = _prompt_for_field(f"{field_name}_item", item_field_info, items_context) + items.append(val) + if not items: + break + return items + + # 2. Handle Nested Models + if isinstance(base_type, type) and issubclass(base_type, BaseModel): + if config.verbose: + config.console.print( + f"\n[bold yellow]↳ Step {current_step}/{total_steps} (Nested): {prompt_text}[/bold yellow]" + ) + return form(base_type, title=f"Nested: {prompt_text}") + + # 3. Standard Field Logic + is_secret = base_type is SecretStr or ( + isinstance(base_type, type) and "Secret" in base_type.__name__ + ) + prompt_type, choices = _get_prompt_class_and_choices(field_info.annotation) + step_prefix = f"[{current_step}/{total_steps}] " if total_steps > 0 else "" + + while True: + try: + display_msg = f"{step_prefix}{prompt_text}" + help_hint = "" + if field_info.json_schema_extra and "help" in field_info.json_schema_extra: # type: ignore + help_hint = " [dim](type ':?' for help)[/dim]" + + if prompt_type is bool: + val = config.engine.ask_confirm( + display_msg + help_hint, + default=bool(default) if default is not None and default != ... else False, + ) + elif choices: + def_val = ( + default.name + if isinstance(default, Enum) + else str(default if (default is not None and default != ...) else "") + ) + val = config.engine.ask_choice(display_msg + help_hint, choices, default=def_val) + else: + def_val = str(default) if default is not None and default != ... else "" + val = config.engine.ask_text( + display_msg + help_hint, default=def_val, password=is_secret + ) + + if isinstance(val, str): + if val == ":?": + config.console.print( + f"[cyan]Help:[/cyan] {field_info.json_schema_extra.get('help', 'No help available')}" # type: ignore + ) + continue + if val in (":b", ":back"): + return "__BACK__" + + if ( + _is_optional_type(field_info.annotation) + and val == "" + and (default is None or default == ...) + ): + return None + + field_type = field_info.annotation or Any + ValidatorModel = create_model( + "_ValidatorModel", **{field_name: (field_type, field_info)} + ) # type: ignore + instance = ValidatorModel(**{field_name: val}) + return getattr(instance, field_name) + except ValidationError as e: + if config.verbose: + config.console.print( + f"[bold red]Validation Error:[/bold red] {e.errors()[0]['msg']}" + ) + continue + + +def form( + model_class: type[T], + title: str | None = None, + exclude: set[str] | None = None, + hydrate_from: list[dict[str, Any]] | None = None, +) -> T: + display_title = title or model_class.__name__ + exclude_set = exclude or set() + hydration_sources = hydrate_from or [] + + if config.verbose and not (title and title.startswith("Nested:")): + config.console.print(Panel(f"[bold cyan]Completing Form:[/bold cyan] {display_title}")) + + collected_data: dict[str, Any] = {} + fields = list(model_class.model_fields.items()) + active_fields = [(n, f) for n, f in fields if n not in exclude_set] + + idx = 0 + while idx < len(active_fields): + field_name, field_info = active_fields[idx] + if not _should_show_field(field_info, collected_data): + idx += 1 + continue + + hydrated_val = None + for source in hydration_sources: + if field_name.upper() in source: + hydrated_val = source[field_name.upper()] + elif field_name in source: + hydrated_val = source[field_name] + + if hydrated_val is not None: + try: + ValidatorModel = create_model( + "_V", **{field_name: (field_info.annotation, field_info)} + ) # type: ignore + instance = ValidatorModel(**{field_name: hydrated_val}) + collected_data[field_name] = getattr(instance, field_name) + if config.verbose: + config.console.print(f"[dim][Auto-filled {field_name}][/dim]") + break + except ValidationError: + hydrated_val = None + + if hydrated_val is not None: + idx += 1 + continue + + res = _prompt_for_field( + field_name, + field_info, + collected_data, + current_step=idx + 1, + total_steps=len(active_fields), + ) + if res == "__BACK__": + if idx > 0: + idx -= 1 + collected_data.pop(active_fields[idx][0], None) + continue + else: + config.console.print("[yellow]Already at the first field.[/yellow]") + continue + + collected_data[field_name] = res + idx += 1 + + if config.verbose and not (title and title.startswith("Nested:")): + config.console.print( + f"\n[bold green]✔ Form {display_title} completed successfully![/bold green]" + ) + + return model_class(**collected_data) diff --git a/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc index 17b4eb4..b637b68 100644 Binary files a/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc and b/tests/__pycache__/test_core.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_extended.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_extended.cpython-313-pytest-9.0.3.pyc index ee04a56..9c8377b 100644 Binary files a/tests/__pycache__/test_extended.cpython-313-pytest-9.0.3.pyc and b/tests/__pycache__/test_extended.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/test_core.py b/tests/test_core.py index 4b547ba..b3ca17c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock from pydantic import BaseModel, Field, SecretStr -from pytypeform import form -from pytypeform.core import PromptEngine, set_engine, config +from typeform import form +from typeform.core import PromptEngine, set_engine, config import io from rich.console import Console diff --git a/tests/test_extended.py b/tests/test_extended.py index 45bf75c..00042d5 100644 --- a/tests/test_extended.py +++ b/tests/test_extended.py @@ -2,8 +2,8 @@ from typing import Literal, Optional, Any, Dict from enum import Enum from pydantic import BaseModel, Field, SecretStr -from pytypeform import form -from pytypeform.core import PromptEngine, set_engine, config +from typeform import form +from typeform.core import PromptEngine, set_engine, config import io from rich.console import Console