diff --git a/.github/workflows/linter_require_ruff.yaml b/.github/workflows/linter_require_ruff.yaml index 3d75a39..c9c7f89 100644 --- a/.github/workflows/linter_require_ruff.yaml +++ b/.github/workflows/linter_require_ruff.yaml @@ -25,7 +25,7 @@ jobs: - name: Add uv to path run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Setup - run: make setup + run: uv sync && make install_tools - name: Run ruff run: make ruff - name: Run complexity check diff --git a/CLAUDE.md b/CLAUDE.md index 1609a17..c424a66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,9 @@ Super-opinionated Python stack for fast development. Python >= 3.12 required. Us ## Common Commands ```bash -# Setup & Run -make init name=... description=... # Initialize project name and description -make setup # Create/update .venv and sync dependencies -make all # Run main.py with setup +# Onboarding & Setup +make onboard # Interactive onboarding CLI (rename, deps, env, hooks, media) +make all # Sync deps and run main.py # Testing make test # Run pytest on tests/ @@ -138,6 +137,7 @@ Structure as: `init()` โ†’ `continue(id)` โ†’ `cleanup(id)` - **Protected Branch**: `main` is protected. Do not push directly to `main`. Use PRs. - **Merge Strategy**: Squash and merge. - **Never force push**: Do not use `git push --force` or `--force-with-lease`. If you hit a git issue, stop and ask the user for guidance. +- **Pre-commit CI gate**: Always run `make ci` before committing any changes. Ensure it passes with zero errors. Do not commit if `make ci` fails - fix all issues first, then commit. ## Deprecated diff --git a/Makefile b/Makefile index 1340ee9..6435c37 100644 --- a/Makefile +++ b/Makefile @@ -36,19 +36,9 @@ help: ## Show this help message ######################################################## ### Initialization -.PHONY: init banner logo -init: ## Initialize project (usage: make init name=my-project description="my description") - @if [ -z "$(name)" ] || [ -z "$(description)" ]; then \ - echo "$(RED)Error: Both 'name' and 'description' parameters are required$(RESET)"; \ - echo "Usage: make init name= description="; \ - exit 1; \ - fi - @echo "$(YELLOW)๐Ÿš€ Initializing project $(name)...$(RESET)" - @sed -i.bak "s/name = \"python-template\"/name = \"$(name)\"/" pyproject.toml && rm pyproject.toml.bak - @sed -i.bak "s/description = \"Add your description here\"/description = \"$(description)\"/" pyproject.toml && rm pyproject.toml.bak - @sed -i.bak "s/# Python-Template/# $(name)/" README.md && rm README.md.bak - @sed -i.bak "s/Opinionated Python project stack. ๐Ÿ”‹ Batteries included. <\/b>/$(description)<\/b>/" README.md && rm README.md.bak - @echo "$(GREEN)โœ… Updated project name and description.$(RESET)" +.PHONY: onboard banner logo +onboard: check_uv ## Run interactive onboarding CLI + @$(PYTHON) onboard.py banner: check_uv ## Generate project banner image @echo "$(YELLOW)๐Ÿ”Generating banner...$(RESET)" @@ -84,31 +74,10 @@ check_jq: jq --version; \ fi -######################################################## -# Setup githooks for linting -######################################################## -setup_githooks: - @echo "$(YELLOW)๐Ÿ”จSetting up githooks on post-commit...$(RESET)" - chmod +x .githooks/post-commit - git config core.hooksPath .githooks - - ######################################################## # Python dependency-related ######################################################## -### Setup & Dependencies -setup: check_uv ## Create venv and sync dependencies - @echo "$(YELLOW)๐Ÿ”ŽLooking for .venv...$(RESET)" - @if [ ! -d ".venv" ]; then \ - echo "$(YELLOW)VS Code is not detected. Creating a new one...$(RESET)"; \ - uv venv; \ - else \ - echo "$(GREEN)โœ….venv is detected.$(RESET)"; \ - fi - @echo "$(YELLOW)๐Ÿ”„Updating python dependencies...$(RESET)" - @uv sync - view_python_venv_size: @echo "$(YELLOW)๐Ÿ”Checking python venv size...$(RESET)" @PYTHON_VERSION=$$(cat .python-version | cut -d. -f1,2) && \ @@ -126,7 +95,8 @@ view_python_venv_size_by_libraries: ######################################################## ### Running -all: setup setup_githooks ## Setup and run main application +all: check_uv ## Sync dependencies and run main application + @uv sync @echo "$(GREEN)๐ŸRunning main application...$(RESET)" @$(PYTHON) main.py @echo "$(GREEN)โœ… Main application run completed.$(RESET)" diff --git a/README.md b/README.md index 0b4887f..012e6cd 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,15 @@ Opinionated Python stack for fast development. The `saas` branch extends `main` ## Quick Start -- `make init name=my-project description="My project description"` - initialize project -- `make all` - runs `main.py` +- `make onboard` - interactive onboarding CLI (rename, deps, env, hooks, media) +- `make all` - sync deps and run `main.py` - `make fmt` - runs `ruff format` + JSON formatting -- `make banner` - create a new banner that makes the README nice ๐Ÿ˜Š - `make test` - runs all tests in `tests/` - `make ci` - runs all CI checks (ruff, vulture, ty, etc.) + ## Configuration ```python diff --git a/init/generate_banner.py b/init/generate_banner.py index cc66008..c9209b2 100644 --- a/init/generate_banner.py +++ b/init/generate_banner.py @@ -15,8 +15,8 @@ class BannerDescription(dspy.Signature): """Generate a creative description of a person/animal/object holding a banner. Go for a japanese style, creative and fun, but make sense.""" title: str = dspy.InputField() - suggestion: str = dspy.InputField( - desc="Optional suggestion to guide the banner description generation" + theme: str = dspy.InputField( + desc="Optional theme/style suggestion to guide the banner description generation" ) banner_description: str = dspy.OutputField( desc="A creative description of a person/animal/object holding a banner with the given title. Do not mention any colors" @@ -26,7 +26,13 @@ class BannerDescription(dspy.Signature): client = genai.Client(api_key=global_config.GEMINI_API_KEY) -async def generate_banner(title: str, suggestion: str | None = None) -> Image.Image: +async def generate_banner(title: str, theme: str | None = None) -> Image.Image: + """Generate a banner image for the project. + + Args: + title: The project title to display on the banner. + theme: Optional theme/style suggestion to guide image generation. + """ # First, use LLM to generate a creative banner description inf_module = DSPYInference( pred_signature=BannerDescription, @@ -35,7 +41,7 @@ async def generate_banner(title: str, suggestion: str | None = None) -> Image.Im result = await inf_module.run( title=title, - suggestion=suggestion or "", + theme=theme or "", ) print(result.banner_description) @@ -72,5 +78,5 @@ async def generate_banner(title: str, suggestion: str | None = None) -> Image.Im if __name__ == "__main__": title = "Python-Template" - suggestion = "use a snake in the image" - asyncio.run(generate_banner(title, suggestion)) + theme = "use a snake in the image" + asyncio.run(generate_banner(title, theme)) diff --git a/init/generate_logo.py b/init/generate_logo.py index 9fc9951..eb4cb4c 100644 --- a/init/generate_logo.py +++ b/init/generate_logo.py @@ -16,8 +16,8 @@ class WordmarkDescription(dspy.Signature): """Generate a creative description for a horizontal wordmark logo with text. The wordmark should be clean, modern, and professional.""" project_name: str = dspy.InputField() - suggestion: str = dspy.InputField( - desc="Optional suggestion to guide the wordmark description generation" + theme: str = dspy.InputField( + desc="Optional theme/style suggestion to guide the wordmark description generation" ) wordmark_description: str = dspy.OutputField( desc="A creative description for a horizontal wordmark logo that includes the project name as text. Focus on typography, icon placement, and professional branding. The wordmark should be wide and horizontal." @@ -93,7 +93,7 @@ def invert_colors(img: Image.Image) -> Image.Image: async def generate_logo( - project_name: str, suggestion: str | None = None, output_dir: Path | None = None + project_name: str, theme: str | None = None, output_dir: Path | None = None ) -> dict[str, Image.Image]: """Generate logo assets using AI-powered pipeline with consistent branding: 1. Generate light mode wordmark with greenscreen @@ -107,7 +107,7 @@ async def generate_logo( Args: project_name: Name of the project - suggestion: Optional suggestion to guide the logo generation + theme: Optional theme/style suggestion to guide the logo generation output_dir: Output directory for the generated images. Defaults to docs/public/ Returns: @@ -128,7 +128,7 @@ async def generate_logo( wordmark_inf = DSPYInference(pred_signature=WordmarkDescription, observe=False) wordmark_result = await wordmark_inf.run( project_name=project_name, - suggestion=suggestion or "", + theme=theme or "", ) print(f"Wordmark description: {wordmark_result.wordmark_description}") @@ -264,5 +264,5 @@ async def generate_logo( if __name__ == "__main__": project_name = "Python-Template" - suggestion = "incorporate python snake and modern tech aesthetics, simple and clean" - asyncio.run(generate_logo(project_name, suggestion)) + theme = "incorporate python snake and modern tech aesthetics, simple and clean" + asyncio.run(generate_logo(project_name, theme)) diff --git a/onboard.py b/onboard.py new file mode 100644 index 0000000..e314096 --- /dev/null +++ b/onboard.py @@ -0,0 +1,551 @@ +"""Interactive onboarding CLI for project setup.""" + +import asyncio +import os +import re +import shutil +import subprocess +from pathlib import Path + +import questionary +import typer +import yaml +from rich import print as rprint +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +console = Console() + +PROJECT_ROOT = Path(__file__).parent + +app = typer.Typer( + name="onboard", + help="Interactive onboarding CLI for project setup.", + invoke_without_command=True, +) + + +def _read_pyproject_name() -> str: + """Read the current project name from pyproject.toml.""" + text = (PROJECT_ROOT / "pyproject.toml").read_text() + match = re.search(r'^name\s*=\s*"([^"]*)"', text, re.MULTILINE) + return match.group(1) if match else "" + + +def _validate_kebab_case(value: str) -> bool | str: + """Validate that the value is kebab-case (lowercase, hyphens, no spaces).""" + if not value: + return "Project name cannot be empty." + if not re.match(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$", value): + return "Must be kebab-case (e.g. my-cool-project). Lowercase letters, digits, hyphens only." + return True + + +STEPS: list[tuple[str, str]] = [ + ("Rename", "rename"), + ("Dependencies", "deps"), + ("Environment Variables", "env"), + ("Pre-commit Hooks", "hooks"), + ("Media Generation", "media"), +] + +STEP_FUNCTIONS: dict[str, object] = {} + + +def _run_orchestrator() -> None: + """Run the full onboarding flow, executing all steps in sequence.""" + project_name = _read_pyproject_name() + rprint( + Panel( + f"[bold]{project_name}[/bold]\n\n" + "This wizard will guide you through:\n" + " 1. Rename - Set project name and description\n" + " 2. Dependencies - Install project dependencies\n" + " 3. Environment - Configure API keys and secrets\n" + " 4. Hooks - Activate pre-commit hooks\n" + " 5. Media - Generate banner and logo assets", + title="Welcome to Project Onboarding", + border_style="blue", + ) + ) + + total = len(STEPS) + completed: list[str] = [] + skipped: list[str] = [] + + for i, (label, cmd_name) in enumerate(STEPS, 1): + rprint(f"\n[bold cyan]--- Step {i}/{total}: {label} ---[/bold cyan]") + answer = questionary.select( + "Run this step?", + choices=["Yes", "Skip"], + default="Yes", + ).ask() + if answer is None: + raise typer.Abort() + + if answer == "Skip": + skipped.append(label) + rprint(f"[yellow]- {label} skipped[/yellow]") + continue + + try: + step_fn = STEP_FUNCTIONS[cmd_name] + step_fn() # type: ignore[operator] + completed.append(label) + except (typer.Exit, SystemExit) as exc: + code = getattr(exc, "code", getattr(exc, "exit_code", 1)) + if code != 0: + rprint(f"[red]โœ— {label} failed.[/red]") + cont = questionary.confirm( + "Continue with remaining steps?", default=True + ).ask() + if cont is None or not cont: + raise typer.Abort() from None + skipped.append(f"{label} (failed)") + else: + completed.append(label) + + _print_summary(completed, skipped) + + +def _print_summary(completed: list[str], skipped: list[str]) -> None: + """Print the final onboarding summary.""" + lines: list[str] = [] + for name in completed: + lines.append(f"[green]โœ“[/green] {name}") + for name in skipped: + lines.append(f"[yellow]-[/yellow] {name}") + lines.append("") + lines.append("[bold]Suggested next commands:[/bold]") + lines.append(" make test - Run tests") + lines.append(" make ci - Run CI checks") + lines.append(" make all - Run main application") + + rprint(Panel("\n".join(lines), title="Onboarding Summary", border_style="green")) + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context) -> None: + """Run the full onboarding flow, or use a subcommand for a specific step.""" + if ctx.invoked_subcommand is None: + _run_orchestrator() + + +@app.command() +def rename() -> None: + """Step 1: Rename the project and update metadata.""" + current_name = _read_pyproject_name() + if current_name != "python-template": + rprint( + f"[blue]โ„น Project already renamed to '{current_name}'. Skipping rename step.[/blue]" + ) + return + + name = questionary.text( + "Project name (kebab-case):", + validate=_validate_kebab_case, + ).ask() + if name is None: + raise typer.Abort() + + description = questionary.text("Project description:").ask() + if description is None: + raise typer.Abort() + + pyproject_path = PROJECT_ROOT / "pyproject.toml" + pyproject_text = pyproject_path.read_text() + pyproject_text = pyproject_text.replace( + 'name = "python-template"', f'name = "{name}"' + ) + if description: + pyproject_text = pyproject_text.replace( + 'description = "Add your description here"', + f'description = "{description}"', + ) + pyproject_path.write_text(pyproject_text) + + readme_path = PROJECT_ROOT / "README.md" + readme_text = readme_path.read_text() + readme_text = readme_text.replace("# Python-Template", f"# {name}", 1) + if description: + readme_text = readme_text.replace( + "Opinionated Python project stack. ๐Ÿ”‹ Batteries included. ", + f"{description}", + 1, + ) + readme_path.write_text(readme_text) + + changes = [f"[green]pyproject.toml[/green] name โ†’ {name}"] + if description: + changes.append(f"[green]pyproject.toml[/green] description โ†’ {description}") + changes.append(f"[green]README.md[/green] heading โ†’ # {name}") + if description: + changes.append(f"[green]README.md[/green] tagline โ†’ {description}") + + rprint(Panel("\n".join(changes), title="โœ… Rename Complete", border_style="green")) + + +@app.command() +def deps() -> None: + """Step 2: Install project dependencies.""" + if not shutil.which("uv"): + rprint( + "[red]โœ— uv is not installed.[/red]\n" + " Install it from: [link=https://docs.astral.sh/uv]https://docs.astral.sh/uv[/link]" + ) + raise typer.Exit(code=1) + + venv_path = PROJECT_ROOT / ".venv" + if not venv_path.is_dir(): + with console.status("[yellow]Creating virtual environment...[/yellow]"): + result = subprocess.run( + ["uv", "venv"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if result.returncode != 0: + rprint(f"[red]โœ— Failed to create venv:[/red]\n{result.stderr}") + raise typer.Exit(code=1) + rprint("[green]โœ“[/green] Virtual environment created.") + + with console.status("[yellow]Installing dependencies (uv sync)...[/yellow]"): + result = subprocess.run( + ["uv", "sync"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if result.returncode != 0: + rprint(f"[red]โœ— uv sync failed:[/red]\n{result.stderr}") + raise typer.Exit(code=1) + + rprint("[green]โœ“ Dependencies installed successfully.[/green]") + + +def _is_secret_key(name: str) -> bool: + """Check if an env var name suggests a secret value.""" + return any(word in name.upper() for word in ("SECRET", "KEY", "TOKEN", "PASSWORD")) + + +def _parse_env_example() -> list[dict[str, str]]: + """Parse .env.example into a list of entries with group, key, and default value. + + Returns a list of dicts with keys: 'group', 'key', 'default'. + Comment-only lines set the current group. Blank lines are skipped. + """ + env_example_path = PROJECT_ROOT / ".env.example" + if not env_example_path.exists(): + return [] + + entries: list[dict[str, str]] = [] + current_group = "General" + + for line in env_example_path.read_text().splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("#"): + current_group = line.lstrip("# ").strip() + continue + if "=" in line: + key, _, default = line.partition("=") + entries.append( + {"group": current_group, "key": key.strip(), "default": default.strip()} + ) + + return entries + + +def _load_existing_env() -> dict[str, str]: + """Load existing .env file into a dict.""" + env_path = PROJECT_ROOT / ".env" + if not env_path.exists(): + return {} + + result: dict[str, str] = {} + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + result[key.strip()] = value.strip() + return result + + +def _has_real_value(value: str) -> bool: + """Check if an env var value is a real (non-placeholder) value.""" + if not value: + return False + placeholders = { + "sk-...", + "sk-ant-...", + "xai-...", + "gsk_...", + "pplx-...", + "AIza...", + "csk-...", + "sk-lf-...", + "pk-lf-...", + "sk_test_...", + "ghp_...", + "postgresql://user:pass@host:port/db", + "https://your-project.supabase.co", + } + return value not in placeholders + + +def _build_env_choices( + entries: list[dict[str, str]], existing: dict[str, str] +) -> list[questionary.Choice]: + """Build questionary checkbox choices from env entries.""" + choices = [] + for entry in entries: + key = entry["key"] + has_value = _has_real_value(existing.get(key, "")) + label = f"[{entry['group']}] {key}" + if has_value: + label += " (configured)" + choices.append(questionary.Choice(title=label, value=key, checked=has_value)) + return choices + + +def _prompt_env_value(key: str, default: str, current_value: str) -> str: + """Prompt the user for a single env var value, handling existing values.""" + if _has_real_value(current_value): + keep = questionary.confirm( + f"{key} already has a value. Keep existing value?", + default=True, + ).ask() + if keep is None: + raise typer.Abort() + if keep: + return current_value + + prompt_fn = questionary.password if _is_secret_key(key) else questionary.text + default_hint = default if not _is_secret_key(key) else "" + new_value = prompt_fn(f"{key}:", default=default_hint).ask() + if new_value is None: + raise typer.Abort() + return new_value + + +def _write_env_file(entries: list[dict[str, str]], values: dict[str, str]) -> int: + """Write .env file preserving group structure and custom vars. Returns count of skipped keys.""" + # Load existing env and identify custom variables not in .env.example + existing = _load_existing_env() + tracked_keys = {entry["key"] for entry in entries} + custom_vars = {k: v for k, v in existing.items() if k not in tracked_keys} + + lines: list[str] = [] + current_group = "" + skipped = 0 + + for entry in entries: + if entry["group"] != current_group: + if lines: + lines.append("") + lines.append(f"# {entry['group']}") + current_group = entry["group"] + + key = entry["key"] + if key in values: + lines.append(f"{key}={values[key]}") + else: + lines.append(f"# {key}={entry['default']}") + skipped += 1 + + # Preserve custom variables not in .env.example + if custom_vars: + lines.append("") + lines.append("# Custom variables") + for key, value in custom_vars.items(): + lines.append(f"{key}={value}") + + (PROJECT_ROOT / ".env").write_text("\n".join(lines) + "\n") + return skipped + + +@app.command() +def env() -> None: + """Step 3: Configure environment variables.""" + entries = _parse_env_example() + if not entries: + rprint("[red]โœ— No .env.example found.[/red]") + raise typer.Exit(code=1) + + existing = _load_existing_env() + choices = _build_env_choices(entries, existing) + + selected_keys = questionary.checkbox( + "Select environment variables to configure:", + choices=choices, + ).ask() + if selected_keys is None: + raise typer.Abort() + + selected_set = set(selected_keys) + values: dict[str, str] = {} + for entry in entries: + key = entry["key"] + if key not in selected_set: + continue + values[key] = _prompt_env_value(key, entry["default"], existing.get(key, "")) + + skipped = _write_env_file(entries, values) + configured = len(values) + + rprint( + f"\n[green]โœ“ {configured} key(s) configured, {skipped} key(s) skipped.[/green]" + ) + + +@app.command() +def hooks() -> None: + """Step 4: Activate pre-commit hooks.""" + config_path = PROJECT_ROOT / ".pre-commit-config.yaml" + if not config_path.exists(): + rprint("[red]โœ— .pre-commit-config.yaml not found.[/red]") + raise typer.Exit(code=1) + + config = yaml.safe_load(config_path.read_text()) + + table = Table(title="Configured Pre-commit Hooks") + table.add_column("Hook ID", style="cyan") + table.add_column("Description", style="white") + + for repo in config.get("repos", []): + for hook in repo.get("hooks", []): + hook_id = hook.get("id", "unknown") + hook_name = hook.get("name", hook_id) + table.add_row(hook_id, hook_name) + + console.print(table) + rprint("") + + activate = questionary.confirm( + "Activate pre-commit hooks? (Recommended)", + default=True, + ).ask() + if activate is None: + raise typer.Abort() + + if activate: + result = subprocess.run( + ["uv", "run", "pre-commit", "install"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + if result.returncode != 0: + rprint(f"[red]โœ— Failed to activate hooks:[/red]\n{result.stderr}") + raise typer.Exit(code=1) + rprint("[green]โœ“ Pre-commit hooks activated.[/green]") + else: + rprint( + "[yellow]Skipped.[/yellow] You can activate later with: " + "[bold]pre-commit install[/bold]" + ) + + +def _check_gemini_key() -> bool: + """Check if GEMINI_API_KEY is available in .env or environment.""" + if os.environ.get("GEMINI_API_KEY"): + return True + env_path = PROJECT_ROOT / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line.startswith("GEMINI_API_KEY=") and not line.startswith("#"): + value = line.split("=", 1)[1].strip() + return _has_real_value(value) + return False + + +def _run_media_generation(choice: str, project_name: str, theme: str) -> list[str]: + """Run the selected media generation and return list of generated file paths.""" + # Import here to avoid requiring GEMINI_API_KEY for non-media commands + from init.generate_banner import generate_banner as gen_banner + from init.generate_logo import generate_logo as gen_logo + + generated_files: list[str] = [] + + if choice in ("Banner only", "Both"): + with console.status("[yellow]Generating banner...[/yellow]"): + asyncio.run(gen_banner(title=project_name, theme=theme)) + banner_path = PROJECT_ROOT / "media" / "banner.png" + generated_files.append(str(banner_path)) + rprint(f"[green]โœ“[/green] Banner saved to {banner_path}") + + if choice in ("Logo only", "Both"): + with console.status("[yellow]Generating logo...[/yellow]"): + asyncio.run(gen_logo(project_name=project_name, theme=theme)) + logo_dir = PROJECT_ROOT / "docs" / "public" + for name in ( + "logo-light.png", + "logo-dark.png", + "icon-light.png", + "icon-dark.png", + "favicon.ico", + ): + generated_files.append(str(logo_dir / name)) + rprint(f"[green]โœ“[/green] Logo assets saved to {logo_dir}") + + return generated_files + + +@app.command() +def media() -> None: + """Step 5: Generate banner and logo assets.""" + if not _check_gemini_key(): + rprint("[yellow]โš  GEMINI_API_KEY is not configured.[/yellow]") + skip = questionary.confirm("Skip media generation?", default=True).ask() + if skip is None: + raise typer.Abort() + if skip: + rprint("[yellow]Media generation skipped.[/yellow]") + return + + project_name = _read_pyproject_name() + + rprint() + theme = questionary.text( + "Describe the visual theme/style for your project assets:", + default="modern, clean, minimalist tech aesthetic", + ).ask() + if theme is None: + raise typer.Abort() + + choice = questionary.select( + "What would you like to generate?", + choices=["Both", "Banner only", "Logo only", "Skip"], + default="Both", + ).ask() + if choice is None: + raise typer.Abort() + + if choice == "Skip": + rprint("[yellow]Media generation skipped.[/yellow]") + return + + generated_files = _run_media_generation(choice, project_name, theme) + rprint("\n[green]Generated files:[/green]") + for f in generated_files: + rprint(f" {f}") + + +# Register step functions for the orchestrator +STEP_FUNCTIONS.update( + { + "rename": rename, + "deps": deps, + "env": env, + "hooks": hooks, + "media": media, + } +) + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index 8a5ed4e..5eed1b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ "scrubadub>=2.0.1", "pydantic>=2.10.6", "numpy>=2.2.2", + "typer", + "questionary", + "rich>=14.3.1", ] readme = "README.md" requires-python = ">= 3.12" @@ -88,7 +91,8 @@ exclude = [ "src/utils/logging_config.py", "src/utils/context.py", "tests/conftest.py", - "init/" + "init/", + "onboard.py" ] [tool.coverage.run] @@ -106,5 +110,6 @@ show_missing = true [dependency-groups] dev = [ + "pre-commit>=4.5.1", "pytest-check-links>=0.9.1", ] diff --git a/utils/llm/dspy_inference.py b/utils/llm/dspy_inference.py index 962535b..ba39afd 100644 --- a/utils/llm/dspy_inference.py +++ b/utils/llm/dspy_inference.py @@ -1,8 +1,8 @@ +import os from collections.abc import Callable from typing import Any import dspy -from langfuse import observe from litellm.exceptions import RateLimitError, ServiceUnavailableError from loguru import logger as log from tenacity import ( @@ -14,7 +14,13 @@ from common import global_config from common.flags import client -from utils.llm.dspy_langfuse import LangFuseDSPYCallback + + +def _langfuse_configured() -> bool: + """Check if LangFuse credentials are present in environment.""" + return bool( + os.environ.get("LANGFUSE_PUBLIC_KEY") and os.environ.get("LANGFUSE_SECRET_KEY") + ) class DSPYInference: @@ -43,12 +49,13 @@ def __init__( if self.fallback_model_name is not None else None ) - if observe: - # Initialize a LangFuseDSPYCallback and configure the LM instance for generation tracing + self.dspy_config: dict[str, Any] = {"lm": self.lm} + if observe and _langfuse_configured(): + from utils.llm.dspy_langfuse import LangFuseDSPYCallback + self.callback = LangFuseDSPYCallback(pred_signature) - dspy.configure(lm=self.lm, callbacks=[self.callback]) - else: - dspy.configure(lm=self.lm) + self.dspy_config["callbacks"] = [self.callback] + self._use_langfuse_observe = observe and _langfuse_configured() # Agent Intiialization if len(tools) > 0: @@ -81,7 +88,9 @@ async def _run_with_retry( lm: dspy.LM, **kwargs: Any, ) -> Any: - return await self.inference_module_async(**kwargs, lm=lm) + config = {**self.dspy_config, "lm": lm} + with dspy.context(**config): + return await self.inference_module_async(**kwargs, lm=lm) def _build_lm( self, @@ -98,8 +107,7 @@ def _build_lm( max_tokens=max_tokens, ) - @observe() - async def run( + async def _run_inner( self, **kwargs: Any, ) -> Any: @@ -127,3 +135,13 @@ async def run( log.error(f"Error in run: {str(e)}") raise return result + + async def run( + self, + **kwargs: Any, + ) -> Any: + if self._use_langfuse_observe: + from langfuse import observe as langfuse_observe + + return await langfuse_observe()(self._run_inner)(**kwargs) + return await self._run_inner(**kwargs) diff --git a/utils/llm/dspy_langfuse.py b/utils/llm/dspy_langfuse.py index daf6685..af7c6e4 100644 --- a/utils/llm/dspy_langfuse.py +++ b/utils/llm/dspy_langfuse.py @@ -62,7 +62,7 @@ def __init__(self, signature: type[dspy_Signature]) -> None: ) self.current_tool_span = contextvars.ContextVar[Any | None]("current_tool_span") # Initialize Langfuse client - self.langfuse = Langfuse() + self.langfuse: Langfuse = Langfuse() self.input_field_names = signature.input_fields.keys() def on_module_start( # noqa @@ -137,7 +137,7 @@ def on_lm_start( # noqa parent_observation_id = get_client().get_current_observation_id() span_obj: LangfuseGeneration | None = None if trace_id: - span_obj = self.langfuse.generation( + span_obj = self.langfuse.generation( # type: ignore[attr-defined] input=user_input, name=model_name, trace_id=trace_id, @@ -396,7 +396,7 @@ def on_tool_start( # noqa if trace_id: # Create a span for the tool call - tool_span = self.langfuse.span( + tool_span = self.langfuse.span( # type: ignore[attr-defined] name=f"tool:{tool_name}", trace_id=trace_id, parent_observation_id=parent_observation_id, diff --git a/uv.lock b/uv.lock index 5d8fea5..376d127 100644 --- a/uv.lock +++ b/uv.lock @@ -93,6 +93,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/62/96b5217b742805236614f05904541000f55422a6060a90d7fd4ce26c172d/alembic-1.16.4-py3-none-any.whl", hash = "sha256:b05e51e8e82efc1abd14ba2af6392897e145930c3e0a2faf2b0da2f7f7fd660d", size = 247026, upload-time = "2025-07-10T16:17:21.845Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -269,6 +278,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -540,6 +558,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -660,11 +687,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.18.0" +version = "3.24.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] [[package]] @@ -806,7 +833,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" }, { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" }, { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload-time = "2025-06-05T16:48:19.604Z" }, { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" }, { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" }, { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" }, @@ -815,7 +841,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, - { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, @@ -824,7 +849,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, @@ -995,6 +1019,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/fb/ee29329b25ffea61994b98f72f4efdefcca5703de4d9d37d89871b7ddc53/human_id-0.2.0-py3-none-any.whl", hash = "sha256:d2c7f0fea0244114a3a6e9b30343733515522031c6948bfdf3e0294a7d76335e", size = 4466, upload-time = "2022-03-24T23:59:00.422Z" }, ] +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1471,6 +1504,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "2.4.2" @@ -1827,6 +1869,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -2196,15 +2266,19 @@ dependencies = [ { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "questionary" }, + { name = "rich" }, { name = "scrubadub" }, { name = "tenacity" }, { name = "termcolor" }, { name = "ty" }, + { name = "typer" }, { name = "vulture" }, ] [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "pytest-check-links" }, ] @@ -2231,15 +2305,21 @@ requires-dist = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "questionary" }, + { name = "rich", specifier = ">=14.3.1" }, { name = "scrubadub", specifier = ">=2.0.1" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "termcolor", specifier = ">=2.5.0" }, { name = "ty", specifier = ">=0.0.14" }, + { name = "typer" }, { name = "vulture", specifier = ">=2.14" }, ] [package.metadata.requires-dev] -dev = [{ name = "pytest-check-links", specifier = ">=0.9.1" }] +dev = [ + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest-check-links", specifier = ">=0.9.1" }, +] [[package]] name = "pytz" @@ -2339,6 +2419,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] +[[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 = "referencing" version = "0.36.2" @@ -2655,6 +2747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/c5/04b959566c85914b17327e40d25b0535b0209a5a5216006443b769bebe25/scrubadub-2.0.1-py3-none-any.whl", hash = "sha256:44b9004998a03aff4c6b5d9073a52895081742f994470083a7be610b373e62b7", size = 65152, upload-time = "2023-09-01T14:50:25.318Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2884,6 +2985,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" }, ] +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -2935,6 +3051,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "virtualenv" +version = "20.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/54/809199edc537dbace273495ac0884d13df26436e910a5ed4d0ec0a69806b/virtualenv-20.39.0.tar.gz", hash = "sha256:a15f0cebd00d50074fd336a169d53422436a12dfe15149efec7072cfe817df8b", size = 5869141, upload-time = "2026-02-23T18:09:13.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/b4/8268da45f26f4fe84f6eae80a6ca1485ffb490a926afecff75fc48f61979/virtualenv-20.39.0-py3-none-any.whl", hash = "sha256:44888bba3775990a152ea1f73f8e5f566d49f11bbd1de61d426fd7732770043e", size = 5839121, upload-time = "2026-02-23T18:09:11.173Z" }, +] + [[package]] name = "vulture" version = "2.14" @@ -2944,6 +3074,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" }, ] +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + [[package]] name = "webencodings" version = "0.5.1"