diff --git a/pyproject.toml b/pyproject.toml index 3a35acd8..272b080b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "databao-context-engine[snowflake]~=0.6.0", "prettytable>=3.10.0", "databao-agent~=0.2.0", + "questionary>=2.1.1", "streamlit[snowflake]>=1.53.0", "uuid6>=2024.7.10", "pyyaml>=6.0", diff --git a/src/databao_cli/__main__.py b/src/databao_cli/__main__.py index f61d08a2..49a12ecc 100644 --- a/src/databao_cli/__main__.py +++ b/src/databao_cli/__main__.py @@ -4,8 +4,10 @@ import click from click import Context +from databao_cli.labels import LABELS from databao_cli.log.logging import configure_logging from databao_cli.project.layout import ROOT_DOMAIN, ProjectLayout, find_project +from databao_cli.utils import ask_confirm, register_labels @click.group() @@ -24,6 +26,8 @@ def cli(ctx: Context, verbose: bool, project_dir: Path | None) -> None: ctx.ensure_object(dict) ctx.obj["project_dir"] = project_path + register_labels(LABELS) + configure_logging(find_project(project_path), verbose=verbose) @@ -49,7 +53,7 @@ def init(ctx: Context) -> None: try: project_layout = init_impl(project_dir) except ProjectDirDoesnotExistError: - if click.confirm( + if ask_confirm( f"The directory {project_dir.resolve()} does not exist. Do you want to create it?", default=True, ): @@ -69,12 +73,12 @@ def init(ctx: Context) -> None: # except RuntimeError as e: # click.echo(str(e), err=True) - if not click.confirm("\nDo you want to configure a domain now?"): + if not ask_confirm("Do you want to configure a domain now?", default=False): return add_datasource_config_interactive_impl(project_layout, ROOT_DOMAIN) - while click.confirm("\nDo you want to add more datasources?"): + while ask_confirm("Do you want to add more datasources?", default=False): add_datasource_config_interactive_impl(project_layout, ROOT_DOMAIN) diff --git a/src/databao_cli/commands/app.py b/src/databao_cli/commands/app.py index 00198fb8..113e15b4 100644 --- a/src/databao_cli/commands/app.py +++ b/src/databao_cli/commands/app.py @@ -9,7 +9,7 @@ def app_impl(ctx: click.Context) -> None: - click.echo("Starting Databao UI...") + click.echo("Starting Databao web interface...") try: bootstrap_streamlit_app( @@ -20,7 +20,7 @@ def app_impl(ctx: click.Context) -> None: hide_build_context_hint=ctx.obj.get("hide_build_context_hint", False), ) except subprocess.CalledProcessError as e: - click.echo(f"Error running Streamlit: {e}", err=True) + click.echo(f"Error starting web interface: {e}", err=True) sys.exit(1) except KeyboardInterrupt: - click.echo("\nShutting down Databao...") + click.echo("\nShutting down...") diff --git a/src/databao_cli/commands/ask.py b/src/databao_cli/commands/ask.py index e72404c8..bb4a92f1 100644 --- a/src/databao_cli/commands/ask.py +++ b/src/databao_cli/commands/ask.py @@ -36,7 +36,7 @@ def dataframe_to_prettytable(df: pd.DataFrame, max_rows: int = DEFAULT_MAX_DISPL def initialize_agent_from_dce(project_path: Path, model: str | None, temperature: float) -> Agent: - """Initialize the Databao agent using DCE project at the given path.""" + """Initialize the Databao agent using a Context Engine project at the given path.""" project = ProjectLayout(project_path) status = databao_project_status(project) @@ -49,12 +49,12 @@ def initialize_agent_from_dce(project_path: Path, model: str | None, temperature if status == DatabaoProjectStatus.NO_DATASOURCES: click.echo( - f"No datasources configured in project at {project.project_dir}. Add datasources first.", + f"No data sources configured in project at {project.project_dir}. Add data sources first", err=True, ) sys.exit(1) - click.echo(f"Using DCE project: {project.project_dir}") + click.echo(f"Using Context Engine project: {project.project_dir}") _domain = create_domain(project.root_domain_dir) @@ -101,18 +101,18 @@ def display_result(thread: Thread) -> None: def _print_help() -> None: """Print help message for interactive mode.""" - click.echo("Databao REPL") + click.echo("Databao interactive chat") click.echo("Ask questions about your data in natural language.\n") click.echo("Commands:") - click.echo(" \\help - Show this help") - click.echo(" \\clear - Start a new conversation") + click.echo(" \\help - Show this help message") + click.echo(" \\clear - Clear conversation history") click.echo(" \\q - Exit\n") def run_interactive_mode(agent: Agent, show_thinking: bool) -> None: """Run the interactive REPL mode.""" - click.echo("\nDatabao REPL") - click.echo("\nType \\help for available commands.\n") + click.echo("\nDatabao interactive chat") + click.echo("Type \\help for available commands.\n") writer = _create_cli_writer() if show_thinking else None @@ -148,14 +148,14 @@ def run_interactive_mode(agent: Agent, show_thinking: bool) -> None: stream_ask=show_thinking, writer=writer, ) - click.echo("Conversation cleared.\n") + click.echo("Conversation history cleared.\n") continue if command == "help": _print_help() continue - click.echo(f"Unknown command: {user_input}. Type \\help for available commands.\n") + click.echo(f"Unknown command: {user_input}\nType \\help for available commands\n") continue # Process as a question diff --git a/src/databao_cli/commands/context_engine_cli.py b/src/databao_cli/commands/context_engine_cli.py index 60f5265c..72c41c88 100644 --- a/src/databao_cli/commands/context_engine_cli.py +++ b/src/databao_cli/commands/context_engine_cli.py @@ -1,6 +1,8 @@ +import sys from typing import Any import click +import questionary from databao_context_engine import Choice, UserInputCallback @@ -15,19 +17,70 @@ def prompt( show_default: bool = default_value is not None and default_value != "" final_type = click.Choice(type.choices) if isinstance(type, Choice) else str - # click goes infinite loop if user gives emptry string as an input AND default_value is None - # in order to exit this loop we need to set default value to '' (so it gets accepted) - # - # Code snippet from click: - # while True: - # value = prompt_func(prompt) - # if value: - # break - # elif default is not None: - # value = default - # break - default_value = default_value if default_value else "" if final_type is str else None - return click.prompt(text=text, default=default_value, hide_input=is_secret, type=final_type, show_default=show_default) + # Determine if this field is optional + is_optional = "(Optional)" in text + + if isinstance(type, Choice): + is_interactive = sys.stdin.isatty() and sys.stdout.isatty() + if is_interactive: + from databao_cli.labels import LABELS + + choices = [questionary.Choice(title=LABELS.get(choice, choice), value=choice) for choice in type.choices] + result = questionary.select( + text, + choices=choices, + default=default_value if default_value is not None and default_value in type.choices else None, + ).ask() + if result is None: + raise click.Abort() + return result + else: + return click.prompt( + text=text, + default=default_value, + hide_input=is_secret, + type=click.Choice(type.choices), + show_default=show_default, + ) + + if default_value: + final_default = default_value + elif is_optional: + final_default = "" + else: + final_default = None + + is_interactive = sys.stdin.isatty() and sys.stdout.isatty() + if is_interactive and final_type is str: + prompt_func = questionary.password if is_secret else questionary.text + + if not is_optional and final_default is None: + while True: + result = prompt_func(text, default=final_default or "").ask() + if result is None: + raise click.Abort() + value = str(result).strip() + if value: + return value + click.echo("This field is required and cannot be empty. Please try again.") + else: + result = prompt_func(text, default=final_default or "").ask() + if result is None: + raise click.Abort() + return str(result) + else: + if final_type is str and not is_optional and final_default is None: + while True: + value = click.prompt( + text=text, default=final_default, hide_input=is_secret, type=final_type, show_default=show_default + ) + if value and value.strip(): + return value + click.echo("This field is required and cannot be empty. Please try again.") + else: + return click.prompt( + text=text, default=final_default, hide_input=is_secret, type=final_type, show_default=show_default + ) def confirm(self, text: str) -> bool: return click.confirm(text=text) diff --git a/src/databao_cli/commands/datasource/add_datasource_config.py b/src/databao_cli/commands/datasource/add_datasource_config.py index b1bb83e2..7974c665 100644 --- a/src/databao_cli/commands/datasource/add_datasource_config.py +++ b/src/databao_cli/commands/datasource/add_datasource_config.py @@ -6,10 +6,12 @@ DatabaoContextPluginLoader, DatasourceType, ) +from pydantic import ValidationError from databao_cli.commands.context_engine_cli import ClickUserInputCallback from databao_cli.commands.datasource.check_datasource_connection import print_connection_check_results from databao_cli.project.layout import ProjectLayout +from databao_cli.utils import ask_confirm, ask_select, ask_text def add_datasource_config_interactive_impl(project_layout: ProjectLayout, domain: str) -> None: @@ -21,37 +23,46 @@ def add_datasource_config_interactive_impl(project_layout: ProjectLayout, domain datasource_type = _ask_for_datasource_type(plugin_loader.get_all_supported_datasource_types(exclude_file_plugins=True)) - datasource_name = click.prompt("Datasource name?", type=str) + datasource_name = ask_text("Datasource name?") datasource_id = domain_manager.datasource_config_exists(datasource_name=datasource_name) if datasource_id is not None: - click.confirm( + ask_confirm( f"A config file already exists for this datasource {datasource_id.relative_path_to_config_file()}. " f"Do you want to overwrite it?", abort=True, default=False, ) - created_datasource = domain_manager.create_datasource_config_interactively( - datasource_type, datasource_name, ClickUserInputCallback(), overwrite_existing=True - ) + + while True: + try: + created_datasource = domain_manager.create_datasource_config_interactively( + datasource_type, datasource_name, ClickUserInputCallback(), overwrite_existing=True + ) + break + except ValidationError as e: + click.echo(click.style("\nValidation error:", fg="red", bold=True)) + for error in e.errors(): + field_path = ".".join(str(loc) for loc in error["loc"]) + click.echo(click.style(f" • {field_path}: {error['msg']}", fg="red")) + click.echo("\nPlease try again with correct values.\n") datasource_id = created_datasource.datasource.id click.echo( f"{os.linesep}We've created a new config file for your datasource at: " f"{domain_manager.get_config_file_path_for_datasource(datasource_id)}" ) - if click.confirm("\nDo you want to check the connection to this new datasource?"): + if ask_confirm("Do you want to check the connection to this new datasource?", default=True): results = domain_manager.check_datasource_connection(datasource_ids=[datasource_id]) print_connection_check_results(domain, results) def _ask_for_datasource_type(supported_datasource_types: set[DatasourceType]) -> DatasourceType: all_datasource_types = sorted([ds_type.full_type for ds_type in supported_datasource_types]) - config_type = click.prompt( + config_type = ask_select( "What type of datasource do you want to add?", - type=click.Choice(all_datasource_types), - default=all_datasource_types[0] if len(all_datasource_types) == 1 else None, + choices=all_datasource_types, + default=None, ) - click.echo(f"Selected type: {config_type}") return DatasourceType(full_type=config_type) diff --git a/src/databao_cli/commands/status.py b/src/databao_cli/commands/status.py index 7737396c..c0779d5a 100644 --- a/src/databao_cli/commands/status.py +++ b/src/databao_cli/commands/status.py @@ -28,19 +28,19 @@ def status_impl(project_dir: Path) -> str: def _generate_info_string(command_info: DceInfo, domain_infos: list[DceDomainInfo]) -> str: info_lines = [ - f"Databao context engine version: {command_info.version}", - f"Databao agent version: {version('databao-agent')}", - f"Databao context engine storage dir: {command_info.dce_path}", - f"Databao context engine plugins: {command_info.plugin_ids}", + f"Context Engine version: {command_info.version}", + f"Agent version: {version('databao-agent')}", + f"Context Engine storage directory: {command_info.dce_path}", + f"Context Engine plugins: {command_info.plugin_ids}", "", - f"OS name: {sys.platform}", - f"OS architecture: {os.uname().machine if hasattr(os, 'uname') else 'unknown'}", + f"OS: {sys.platform}", + f"Architecture: {os.uname().machine if hasattr(os, 'uname') else 'unknown'}", "", ] for domain_info in domain_infos: if domain_info.is_initialized: - info_lines.append(f"Databao Domain dir: {domain_info.project_path.resolve()}") - info_lines.append(f"Databao Domain ID: {domain_info.project_id!s}") + info_lines.append(f"Domain directory: {domain_info.project_path.resolve()}") + info_lines.append(f"Domain ID: {domain_info.project_id!s}") return os.linesep.join(info_lines) diff --git a/src/databao_cli/labels.py b/src/databao_cli/labels.py new file mode 100644 index 00000000..227b30a9 --- /dev/null +++ b/src/databao_cli/labels.py @@ -0,0 +1,35 @@ +LABELS = { + "athena": "Amazon Athena", + "bigquery": "BigQuery", + "clickhouse": "ClickHouse", + "duckdb": "DuckDB", + "mssql": "Microsoft SQL Server", + "mysql": "MySQL", + "parquet": "Parquet", + "postgres": "PostgreSQL", + "snowflake": "Snowflake", + "sqlite": "SQLite", + "BigQueryDefaultAuth": "Default auth", + "BigQueryServiceAccountJsonAuth": "Service account JSON credentials", + "BigQueryServiceAccountKeyFileAuth": "Service account key file", + "SnowflakeKeyPairAuth": "Key pair", + "SnowflakePasswordAuth": "Password", + "SnowflakeSSOAuth": "SSO", + "connection.auth.type": "Authentication type", + "connection.host": "Host", + "connection.port": "Port", + "connection.database": "Database", + "connection.schema": "Schema", + "connection.username": "Username", + "connection.password": "Password", + "connection.account": "Account", + "connection.warehouse": "Warehouse", + "connection.role": "Role", + "connection.path": "File path", + "connection.project": "Project", + "connection.dataset": "Dataset", + "connection.location": "Location", + "connection.auth.credentials_file": "Credentials file", + "connection.auth.key_file": "Key file", + "connection.auth.token": "Token", +} diff --git a/src/databao_cli/mcp/tools/databao_ask/agent_factory.py b/src/databao_cli/mcp/tools/databao_ask/agent_factory.py index b4645e7b..f9e6ba2d 100644 --- a/src/databao_cli/mcp/tools/databao_ask/agent_factory.py +++ b/src/databao_cli/mcp/tools/databao_ask/agent_factory.py @@ -19,7 +19,7 @@ def create_agent_for_tool( executor: str = "claude_code", cache: Cache | None = None, ) -> Agent: - """Create a Databao agent from a DCE project, configured for MCP tool use. + """Create a Databao agent from a Context Engine project, configured for MCP tool use. Raises ValueError if the project is not ready (no datasources, no build). """ @@ -27,11 +27,11 @@ def create_agent_for_tool( status = databao_project_status(project) if status == DatabaoProjectStatus.NOT_INITIALIZED: - raise ValueError("Databao project is not initialized. Run 'databao init' first.") + raise ValueError("Databao project is not initialized. Run 'databao init' first") if status == DatabaoProjectStatus.NO_DATASOURCES: - raise ValueError("No datasources configured. Run 'databao datasource add' first.") + raise ValueError("No data sources configured. Run 'databao datasource add' first") if not has_build_output(project): - raise ValueError("Project has no build output. Run 'databao build' first.") + raise ValueError("Project has no build output. Run 'databao build' first") domain = create_domain(project.root_domain_dir) diff --git a/src/databao_cli/utils.py b/src/databao_cli/utils.py new file mode 100644 index 00000000..cd319c05 --- /dev/null +++ b/src/databao_cli/utils.py @@ -0,0 +1,76 @@ +import sys +from typing import Any + +import click +import questionary + +_labels: dict[str, str] = {} + + +def register_labels(labels: dict[str, str]) -> None: + _labels.update(labels) + + +def _resolve(value: str) -> tuple[str, str]: + label = _labels.get(value, value) + return value, label + + +def is_interactive() -> bool: + """True when running in an interactive terminal.""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def ask_select(message: str, choices: list[str], default: str | None = None) -> str: + """Select from a list. Interactive in TTY, plain text otherwise.""" + if is_interactive(): + resolved = [_resolve(c) if isinstance(c, str) else c for c in choices] + q_choices = [questionary.Choice(title=label, value=value) for value, label in resolved] + result: Any = questionary.select(message, choices=q_choices, default=default).ask() + if result is None: + raise click.Abort() + return str(result) + else: + click.echo(f"{message}") + for i, choice in enumerate(choices, 1): + click.echo(f" {i}. {choice}") + value: str = click.prompt( + "Enter a number of value", + default=default or choices[0], + ) + if value.isdigit() and 1 <= int(value) <= len(choices): + return choices[int(value) - 1] + if value in choices: + return value + raise click.BadParameter(f"Invalid choice: {value}") + + +def ask_confirm(message: str, default: bool = True, abort: bool = False) -> bool: + """Yes/no. Fancy in TTY, plain click.confirm otherwise.""" + if is_interactive(): + result: Any = questionary.confirm(message, default=default).ask() + if result is None: + raise click.Abort() + if abort and not result: + raise click.Abort() + return bool(result) + else: + return click.confirm(message, default=default, abort=abort) + + +def ask_text(message: str, default: str | None = None, allow_empty: bool = False) -> str: + """Text input. Interactive in TTY, plain click.prompt otherwise.""" + if is_interactive(): + while True: + try: + result: Any = questionary.text(message, default=default or "").ask() + except KeyboardInterrupt: + raise click.Abort() from None + if result is None: + raise click.Abort() + value = str(result) + if value.strip() or allow_empty: + return value + click.echo("Value cannot be empty. Please try again.") + else: + return str(click.prompt(message, default=default)) diff --git a/tests/test_add_datasource.py b/tests/test_add_datasource.py index 6265fe3a..b07124fb 100644 --- a/tests/test_add_datasource.py +++ b/tests/test_add_datasource.py @@ -1,7 +1,9 @@ from pathlib import Path +from unittest.mock import Mock import duckdb import pytest +import questionary from click.testing import CliRunner from databao_cli.__main__ import cli @@ -31,11 +33,14 @@ def temp_parquet_file(request: pytest.FixtureRequest, tmp_path: Path) -> Path: return parquet_file -def test_databao_datasource_add(tmp_path: Path, temp_parquet_file: Path) -> None: +def test_databao_datasource_add(tmp_path: Path, temp_parquet_file: Path, monkeypatch: pytest.MonkeyPatch) -> None: init_databao_project(tmp_path) + prompt = Mock() + prompt.ask.return_value = "parquet" + monkeypatch.setattr(questionary, "select", Mock(return_value=prompt)) + inputs = [ - "parquet", "resources/my_parq", str(temp_parquet_file), "N", # No. skip adding duckdb secret diff --git a/uv.lock b/uv.lock index c8933823..e9bd80e7 100644 --- a/uv.lock +++ b/uv.lock @@ -1009,6 +1009,7 @@ dependencies = [ { name = "nh3" }, { name = "prettytable" }, { name = "pyyaml" }, + { name = "questionary" }, { name = "streamlit", extra = ["snowflake"] }, { name = "uuid6" }, ] @@ -1065,6 +1066,7 @@ requires-dist = [ { name = "nh3", specifier = ">=0.2.15" }, { name = "prettytable", specifier = ">=3.10.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "questionary", specifier = ">=2.1.1" }, { name = "streamlit", extras = ["snowflake"], specifier = ">=1.53.0" }, { name = "uuid6", specifier = ">=2024.7.10" }, ] @@ -1921,7 +1923,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -1930,7 +1931,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -1939,7 +1939,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1948,7 +1947,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -1957,7 +1955,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, @@ -5076,6 +5073,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "rapidocr" version = "3.6.0"