Merge branch 'main' into add_vitest_support_to_js

This commit is contained in:
Sarthak Agarwal 2026-02-01 03:41:43 +05:30 committed by GitHub
commit aa9b926200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3671 additions and 29 deletions

View file

@ -122,6 +122,15 @@ def parse_args() -> Namespace:
"--effort", type=str, help="Effort level for optimization", choices=["low", "medium", "high"], default="medium"
)
# Config management flags
parser.add_argument(
"--show-config", action="store_true", help="Show current or auto-detected configuration and exit."
)
parser.add_argument(
"--reset-config", action="store_true", help="Remove codeflash configuration from project config file."
)
parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts (useful for CI/scripts).")
args, unknown_args = parser.parse_known_args()
sys.argv[:] = [sys.argv[0], *unknown_args]
return process_and_validate_cmd_args(args)
@ -148,6 +157,16 @@ def process_and_validate_cmd_args(args: Namespace) -> Namespace:
logger.info(f"Codeflash version {version}")
sys.exit()
# Handle --show-config
if getattr(args, "show_config", False):
_handle_show_config()
sys.exit()
# Handle --reset-config
if getattr(args, "reset_config", False):
_handle_reset_config(confirm=not getattr(args, "yes", False))
sys.exit()
if args.command == "vscode-install":
install_vscode_extension()
sys.exit()
@ -326,3 +345,91 @@ def handle_optimize_all_arg_parsing(args: Namespace) -> Namespace:
else:
args.all = Path(args.all).resolve()
return args
def _handle_show_config() -> None:
"""Show current or auto-detected Codeflash configuration."""
from rich.table import Table
from codeflash.cli_cmds.console import console
from codeflash.setup.detector import detect_project, has_existing_config
project_root = Path.cwd()
detected = detect_project(project_root)
# Check if config exists or is auto-detected
config_exists = has_existing_config(project_root)
status = "Saved config" if config_exists else "Auto-detected (not saved)"
console.print()
console.print(f"[bold]Codeflash Configuration[/bold] ({status})")
console.print()
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Setting", style="dim")
table.add_column("Value")
table.add_row("Language", detected.language)
table.add_row("Project root", str(detected.project_root))
table.add_row("Module root", str(detected.module_root))
table.add_row("Tests root", str(detected.tests_root) if detected.tests_root else "(not detected)")
table.add_row("Test runner", detected.test_runner or "(not detected)")
table.add_row("Formatter", ", ".join(detected.formatter_cmds) if detected.formatter_cmds else "(not detected)")
table.add_row(
"Ignore paths", ", ".join(str(p) for p in detected.ignore_paths) if detected.ignore_paths else "(none)"
)
table.add_row("Confidence", f"{detected.confidence:.0%}")
console.print(table)
console.print()
if not config_exists:
console.print("[dim]Run [bold]codeflash --file <file>[/bold] to auto-save this config.[/dim]")
def _handle_reset_config(confirm: bool = True) -> None:
"""Remove Codeflash configuration from project config file.
Args:
confirm: If True, prompt for confirmation before removing.
"""
from codeflash.cli_cmds.console import console
from codeflash.setup.config_writer import remove_config
from codeflash.setup.detector import detect_project, has_existing_config
project_root = Path.cwd()
if not has_existing_config(project_root):
console.print("[yellow]No Codeflash configuration found to remove.[/yellow]")
return
detected = detect_project(project_root)
if confirm:
console.print("[bold]This will remove Codeflash configuration from your project.[/bold]")
console.print()
config_file = "pyproject.toml" if detected.language == "python" else "package.json"
console.print(f" Config file: {project_root / config_file}")
console.print()
try:
response = console.input("[bold]Are you sure you want to remove the config? [y/N][/bold] ")
except (EOFError, KeyboardInterrupt):
console.print("\n[yellow]Cancelled.[/yellow]")
return
if response.lower() not in ("y", "yes"):
console.print("[yellow]Cancelled.[/yellow]")
return
success, message = remove_config(project_root, detected.language)
# Escape brackets in message to prevent Rich markup interpretation
escaped_message = message.replace("[", "\\[")
if success:
console.print(f"[green]✓[/green] {escaped_message}")
else:
console.print(f"[red]✗[/red] {escaped_message}")

View file

@ -607,7 +607,33 @@ def check_for_toml_or_setup_file() -> str | None:
curdir = Path.cwd()
pyproject_toml_path = curdir / "pyproject.toml"
setup_py_path = curdir / "setup.py"
package_json_path = curdir / "package.json"
project_name = None
# Check if this might be a JavaScript/TypeScript project that wasn't detected
if package_json_path.exists() and not pyproject_toml_path.exists() and not setup_py_path.exists():
js_redirect_panel = Panel(
Text(
f"📦 I found a package.json in {curdir}.\n\n"
"This looks like a JavaScript/TypeScript project!\n"
"Redirecting to JavaScript setup...",
style="cyan",
),
title="🟨 JavaScript Project Detected",
border_style="bright_yellow",
)
console.print(js_redirect_panel)
console.print()
ph("cli-js-project-redirect")
# Redirect to JS init
from codeflash.cli_cmds.init_javascript import ProjectLanguage, detect_project_language, init_js_project
project_language = detect_project_language()
if project_language in (ProjectLanguage.JAVASCRIPT, ProjectLanguage.TYPESCRIPT):
init_js_project(project_language)
sys.exit(0) # init_js_project handles its own exit, but ensure we don't continue
if pyproject_toml_path.exists():
try:
pyproject_toml_content = pyproject_toml_path.read_text(encoding="utf8")
@ -617,28 +643,44 @@ def check_for_toml_or_setup_file() -> str | None:
except Exception:
click.echo("✅ I found a pyproject.toml for your project.")
ph("cli-pyproject-toml-found")
elif setup_py_path.exists():
setup_py_content = setup_py_path.read_text(encoding="utf8")
project_name_match = re.search(r"setup\s*\([^)]*?name\s*=\s*['\"](.*?)['\"]", setup_py_content, re.DOTALL)
if project_name_match:
project_name = project_name_match.group(1)
click.echo(f"✅ Found setup.py for your project {project_name}")
ph("cli-setup-py-found-name")
else:
click.echo("✅ Found setup.py.")
ph("cli-setup-py-found")
else:
if setup_py_path.exists():
setup_py_content = setup_py_path.read_text(encoding="utf8")
project_name_match = re.search(r"setup\s*\([^)]*?name\s*=\s*['\"](.*?)['\"]", setup_py_content, re.DOTALL)
if project_name_match:
project_name = project_name_match.group(1)
click.echo(f"✅ Found setup.py for your project {project_name}")
ph("cli-setup-py-found-name")
else:
click.echo("✅ Found setup.py.")
ph("cli-setup-py-found")
toml_info_panel = Panel(
Text(
f"💡 No pyproject.toml found in {curdir}.\n\n"
"This file is essential for Codeflash to store its configuration.\n"
"Please ensure you are running `codeflash init` from your project's root directory.",
style="yellow",
),
title="📋 pyproject.toml Required",
border_style="bright_yellow",
)
console.print(toml_info_panel)
# No Python config files found - show appropriate message
# Check again if this might be a JS project
if package_json_path.exists():
js_hint_panel = Panel(
Text(
f"📦 I found a package.json but no pyproject.toml in {curdir}.\n\n"
"If this is a JavaScript/TypeScript project, please run:\n"
" codeflash init\n\n"
"from the project root directory.",
style="yellow",
),
title="🤔 Mixed Project?",
border_style="bright_yellow",
)
console.print(js_hint_panel)
else:
toml_info_panel = Panel(
Text(
f"💡 No pyproject.toml found in {curdir}.\n\n"
"This file is essential for Codeflash to store its configuration.\n"
"Please ensure you are running `codeflash init` from your project's root directory.",
style="yellow",
),
title="📋 pyproject.toml Required",
border_style="bright_yellow",
)
console.print(toml_info_panel)
console.print()
ph("cli-no-pyproject-toml-or-setup-py")

View file

@ -90,13 +90,30 @@ def detect_project_language(project_root: Path | None = None) -> ProjectLanguage
has_package_json = (root / "package.json").exists()
has_tsconfig = (root / "tsconfig.json").exists()
# TypeScript project
# TypeScript project (tsconfig.json is definitive)
if has_tsconfig:
return ProjectLanguage.TYPESCRIPT
# Pure JS project (has package.json but no Python files)
if has_package_json and not has_pyproject and not has_setup_py:
return ProjectLanguage.JAVASCRIPT
# JavaScript project - package.json without Python-specific files takes priority
# Note: If both package.json and pyproject.toml exist, check for typical JS project indicators
if has_package_json:
# If no Python config files, it's definitely JavaScript
if not has_pyproject and not has_setup_py:
return ProjectLanguage.JAVASCRIPT
# If package.json exists with Python files, check for JS-specific indicators
# Common React/Node patterns indicate a JS project
js_indicators = [
(root / "node_modules").exists(),
(root / ".npmrc").exists(),
(root / "yarn.lock").exists(),
(root / "package-lock.json").exists(),
(root / "pnpm-lock.yaml").exists(),
(root / "bun.lockb").exists(),
(root / "bun.lock").exists(),
]
if any(js_indicators):
return ProjectLanguage.JAVASCRIPT
# Python project (default)
return ProjectLanguage.PYTHON

View file

@ -4,6 +4,7 @@ If you might want to work with us on finally making performance a
solved problem, please reach out to us at careers@codeflash.ai. We're hiring!
"""
import sys
from pathlib import Path
from codeflash.cli_cmds.cli import parse_args, process_pyproject_config
@ -39,7 +40,11 @@ def main() -> None:
posthog_cf.initialize_posthog(enabled=not args.disable_telemetry)
ask_run_end_to_end_test(args)
else:
args = process_pyproject_config(args)
# Check for first-run experience (no config exists)
args = _handle_config_loading(args)
if args is None:
sys.exit(0)
if not env_utils.check_formatter_installed(args.formatter_cmds):
return
args.previous_checkpoint_functions = ask_should_use_checkpoint_get_functions(args)
@ -51,6 +56,53 @@ def main() -> None:
optimizer.run_with_args(args)
def _handle_config_loading(args):
"""Handle config loading with first-run experience support.
If no config exists and not in CI, triggers the first-run experience.
Otherwise, loads config normally.
Args:
args: CLI args namespace.
Returns:
Updated args with config loaded, or None if user cancelled first-run.
"""
from codeflash.setup.first_run import handle_first_run, is_first_run
# Check if we're in CI environment
is_ci = any(
var in ("true", "1", "True")
for var in [env_utils.os.environ.get("CI", ""), env_utils.os.environ.get("GITHUB_ACTIONS", "")]
)
# Check if first run (no config exists)
if is_first_run() and not is_ci:
# Skip API key check if already set
skip_api_key = bool(env_utils.os.environ.get("CODEFLASH_API_KEY"))
# Handle first-run experience
result = handle_first_run(args=args, skip_confirm=getattr(args, "yes", False), skip_api_key=skip_api_key)
if result is None:
return None
# Merge first-run results with any CLI overrides
args = result
# Still need to process some config values
# Config might not exist yet if first run just saved it - that's OK
import contextlib
with contextlib.suppress(ValueError):
args = process_pyproject_config(args)
return args
# Normal config loading
return process_pyproject_config(args)
def print_codeflash_banner() -> None:
paneled_text(
CODEFLASH_LOGO, panel_args={"title": "https://codeflash.ai", "expand": False}, text_args={"style": "bold gold3"}

View file

@ -0,0 +1,22 @@
"""Setup module for Codeflash auto-detection and first-run experience.
This module provides:
- Universal project detection across all supported languages
- First-run experience with auto-detection and quick confirm
- Config writing to native config files (pyproject.toml, package.json)
"""
from codeflash.setup.config_schema import CodeflashConfig
from codeflash.setup.config_writer import write_config
from codeflash.setup.detector import DetectedProject, detect_project, has_existing_config
from codeflash.setup.first_run import handle_first_run, is_first_run
__all__ = [
"CodeflashConfig",
"DetectedProject",
"detect_project",
"handle_first_run",
"has_existing_config",
"is_first_run",
"write_config",
]

View file

@ -0,0 +1,197 @@
"""Codeflash configuration schema using Pydantic.
This module provides a language-agnostic internal representation of Codeflash
configuration that can be serialized to different formats (TOML, JSON).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING:
from pathlib import Path
class CodeflashConfig(BaseModel):
"""Internal representation of Codeflash configuration.
This is the canonical config format used internally. It can be converted
to/from pyproject.toml (Python) or package.json (JS/TS) formats.
Note: All paths are stored as strings (relative to project root).
"""
# Core settings (always present after detection)
language: str = Field(description="Project language: python, javascript, typescript")
module_root: str = Field(default=".", description="Root directory containing source code")
tests_root: str | None = Field(default=None, description="Root directory containing tests")
# Tooling settings (auto-detected, can be overridden)
test_runner: str | None = Field(default=None, description="Test runner: pytest, jest, vitest, mocha")
formatter_cmds: list[str] = Field(default_factory=list, description="Formatter commands")
# Optional settings
ignore_paths: list[str] = Field(default_factory=list, description="Paths to ignore")
benchmarks_root: str | None = Field(default=None, description="Benchmarks directory")
# Git settings
git_remote: str = Field(default="origin", description="Git remote for PRs")
# Privacy settings
disable_telemetry: bool = Field(default=False, description="Disable telemetry")
# Python-specific settings
pytest_cmd: str = Field(default="pytest", description="Pytest command (Python only)")
disable_imports_sorting: bool = Field(default=False, description="Disable import sorting (Python only)")
override_fixtures: bool = Field(default=False, description="Override test fixtures (Python only)")
model_config = ConfigDict(extra="allow") # Allow extra fields for forward compatibility
def to_pyproject_dict(self) -> dict[str, Any]:
"""Convert to pyproject.toml [tool.codeflash] format.
Uses kebab-case keys as per TOML conventions.
Only includes non-default values to keep config minimal.
"""
config: dict[str, Any] = {}
# Always include required fields
config["module-root"] = self.module_root
if self.tests_root:
config["tests-root"] = self.tests_root
# Include non-default optional fields
if self.ignore_paths:
config["ignore-paths"] = self.ignore_paths
if self.formatter_cmds and self.formatter_cmds != ["black $file"]:
config["formatter-cmds"] = self.formatter_cmds
elif not self.formatter_cmds:
config["formatter-cmds"] = ["disabled"]
if self.benchmarks_root:
config["benchmarks-root"] = self.benchmarks_root
if self.git_remote and self.git_remote != "origin":
config["git-remote"] = self.git_remote
if self.disable_telemetry:
config["disable-telemetry"] = True
if self.pytest_cmd and self.pytest_cmd != "pytest":
config["pytest-cmd"] = self.pytest_cmd
if self.disable_imports_sorting:
config["disable-imports-sorting"] = True
if self.override_fixtures:
config["override-fixtures"] = True
return config
def to_package_json_dict(self) -> dict[str, Any]:
"""Convert to package.json codeflash section format.
Uses camelCase keys as per JSON/JS conventions.
Only includes values that override auto-detection.
"""
config: dict[str, Any] = {}
# Module root (only if not auto-detected default)
if self.module_root and self.module_root not in (".", "src"):
config["moduleRoot"] = self.module_root
# Formatter (only if explicitly set)
if self.formatter_cmds:
config["formatterCmds"] = self.formatter_cmds
# Ignore paths (only if set)
if self.ignore_paths:
config["ignorePaths"] = self.ignore_paths
# Benchmarks root
if self.benchmarks_root:
config["benchmarksRoot"] = self.benchmarks_root
# Git remote (only if not default)
if self.git_remote and self.git_remote != "origin":
config["gitRemote"] = self.git_remote
# Telemetry
if self.disable_telemetry:
config["disableTelemetry"] = True
return config
@classmethod
def from_detected_project(cls, detected: Any) -> CodeflashConfig:
"""Create config from DetectedProject.
Args:
detected: DetectedProject instance from detector.
Returns:
CodeflashConfig instance.
"""
return cls(
language=detected.language,
module_root=str(detected.module_root.relative_to(detected.project_root))
if detected.module_root != detected.project_root
else ".",
tests_root=str(detected.tests_root.relative_to(detected.project_root)) if detected.tests_root else None,
test_runner=detected.test_runner,
formatter_cmds=detected.formatter_cmds,
ignore_paths=[
str(p.relative_to(detected.project_root)) for p in detected.ignore_paths if p != detected.project_root
],
pytest_cmd=detected.test_runner if detected.language == "python" else "pytest",
)
@classmethod
def from_pyproject_dict(cls, data: dict[str, Any], project_root: Path | None = None) -> CodeflashConfig:
"""Create config from pyproject.toml [tool.codeflash] section.
Args:
data: Dict from [tool.codeflash] section.
project_root: Project root path (reserved for future path resolution).
Returns:
CodeflashConfig instance.
"""
_ = project_root # Reserved for future path resolution
def convert_key(key: str) -> str:
"""Convert kebab-case to snake_case."""
return key.replace("-", "_")
converted = {convert_key(k): v for k, v in data.items()}
converted.setdefault("language", "python")
return cls(**converted)
@classmethod
def from_package_json_dict(cls, data: dict[str, Any], project_root: Path | None = None) -> CodeflashConfig:
"""Create config from package.json codeflash section.
Args:
data: Dict from package.json "codeflash" key.
project_root: Project root path (reserved for future path resolution).
Returns:
CodeflashConfig instance.
"""
_ = project_root # Reserved for future path resolution
def convert_key(key: str) -> str:
"""Convert camelCase to snake_case."""
import re
return re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower()
converted = {convert_key(k): v for k, v in data.items()}
converted.setdefault("language", "javascript")
return cls(**converted)

View file

@ -0,0 +1,246 @@
"""Config writer for native config files.
This module writes Codeflash configuration to native config files:
- Python: pyproject.toml [tool.codeflash]
- JavaScript/TypeScript: package.json { "codeflash": {} }
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import tomlkit
if TYPE_CHECKING:
from pathlib import Path
from codeflash.setup.config_schema import CodeflashConfig
from codeflash.setup.detector import DetectedProject
def write_config(detected: DetectedProject, config: CodeflashConfig | None = None) -> tuple[bool, str]:
"""Write Codeflash config to the appropriate native config file.
Args:
detected: DetectedProject with project information.
config: Optional CodeflashConfig to write. If None, creates from detected.
Returns:
Tuple of (success, message).
"""
from codeflash.setup.config_schema import CodeflashConfig
if config is None:
config = CodeflashConfig.from_detected_project(detected)
if detected.language == "python":
return _write_pyproject_toml(detected.project_root, config)
return _write_package_json(detected.project_root, config)
def _write_pyproject_toml(project_root: Path, config: CodeflashConfig) -> tuple[bool, str]:
"""Write config to pyproject.toml [tool.codeflash] section.
Creates pyproject.toml if it doesn't exist.
Preserves existing content and formatting.
Args:
project_root: Project root directory.
config: CodeflashConfig to write.
Returns:
Tuple of (success, message).
"""
pyproject_path = project_root / "pyproject.toml"
try:
# Load existing or create new
if pyproject_path.exists():
with pyproject_path.open("rb") as f:
doc = tomlkit.parse(f.read())
else:
doc = tomlkit.document()
# Ensure [tool] section exists
if "tool" not in doc:
doc["tool"] = tomlkit.table()
# Create codeflash section
codeflash_table = tomlkit.table()
codeflash_table.add(tomlkit.comment("Codeflash configuration - https://docs.codeflash.ai"))
# Add config values
config_dict = config.to_pyproject_dict()
for key, value in config_dict.items():
codeflash_table[key] = value
# Update the document
doc["tool"]["codeflash"] = codeflash_table
# Write back
with pyproject_path.open("w", encoding="utf8") as f:
f.write(tomlkit.dumps(doc))
return True, f"Config saved to {pyproject_path}"
except Exception as e:
return False, f"Failed to write pyproject.toml: {e}"
def _write_package_json(project_root: Path, config: CodeflashConfig) -> tuple[bool, str]:
"""Write config to package.json codeflash section.
Preserves existing content and formatting.
Creates minimal config (only non-default values).
Args:
project_root: Project root directory.
config: CodeflashConfig to write.
Returns:
Tuple of (success, message).
"""
package_json_path = project_root / "package.json"
if not package_json_path.exists():
return False, f"No package.json found at {project_root}"
try:
# Load existing
with package_json_path.open(encoding="utf8") as f:
doc = json.load(f)
# Get config dict (only non-default values)
config_dict = config.to_package_json_dict()
# Update or remove codeflash section
if config_dict:
doc["codeflash"] = config_dict
action = "Updated"
else:
# Remove codeflash section if empty (all defaults)
doc.pop("codeflash", None)
action = "Using auto-detected defaults (no config needed)"
# Write back with nice formatting
with package_json_path.open("w", encoding="utf8") as f:
json.dump(doc, f, indent=2)
f.write("\n") # Trailing newline
if config_dict:
return True, f"{action} config in {package_json_path}"
return True, action
except json.JSONDecodeError as e:
return False, f"Invalid JSON in package.json: {e}"
except Exception as e:
return False, f"Failed to write package.json: {e}"
def create_pyproject_toml(project_root: Path) -> tuple[bool, str]:
"""Create a minimal pyproject.toml file.
Used when no pyproject.toml exists for a Python project.
Args:
project_root: Project root directory.
Returns:
Tuple of (success, message).
"""
pyproject_path = project_root / "pyproject.toml"
if pyproject_path.exists():
return False, f"pyproject.toml already exists at {pyproject_path}"
try:
doc = tomlkit.document()
doc.add(tomlkit.comment("Created by Codeflash"))
doc.add(tomlkit.nl())
# Add minimal [tool.codeflash] section
tool_table = tomlkit.table()
codeflash_table = tomlkit.table()
codeflash_table.add(tomlkit.comment("Codeflash configuration - https://docs.codeflash.ai"))
tool_table["codeflash"] = codeflash_table
doc["tool"] = tool_table
with pyproject_path.open("w", encoding="utf8") as f:
f.write(tomlkit.dumps(doc))
return True, f"Created {pyproject_path}"
except Exception as e:
return False, f"Failed to create pyproject.toml: {e}"
def remove_config(project_root: Path, language: str) -> tuple[bool, str]:
"""Remove Codeflash config from native config file.
Args:
project_root: Project root directory.
language: Project language ("python", "javascript", "typescript").
Returns:
Tuple of (success, message).
"""
if language == "python":
return _remove_from_pyproject(project_root)
return _remove_from_package_json(project_root)
def _remove_from_pyproject(project_root: Path) -> tuple[bool, str]:
"""Remove [tool.codeflash] section from pyproject.toml."""
pyproject_path = project_root / "pyproject.toml"
if not pyproject_path.exists():
return True, "No pyproject.toml found"
try:
with pyproject_path.open("rb") as f:
doc = tomlkit.parse(f.read())
if "tool" in doc and "codeflash" in doc["tool"]:
del doc["tool"]["codeflash"]
with pyproject_path.open("w", encoding="utf8") as f:
f.write(tomlkit.dumps(doc))
return True, "Removed [tool.codeflash] section from pyproject.toml"
return True, "No codeflash config found in pyproject.toml"
except Exception as e:
return False, f"Failed to remove config: {e}"
def _remove_from_package_json(project_root: Path) -> tuple[bool, str]:
"""Remove codeflash section from package.json."""
package_json_path = project_root / "package.json"
if not package_json_path.exists():
return True, "No package.json found"
try:
with package_json_path.open(encoding="utf8") as f:
doc = json.load(f)
if "codeflash" in doc:
del doc["codeflash"]
with package_json_path.open("w", encoding="utf8") as f:
json.dump(doc, f, indent=2)
f.write("\n")
return True, "Removed codeflash section from package.json"
return True, "No codeflash config found in package.json"
except Exception as e:
return False, f"Failed to remove config: {e}"

692
codeflash/setup/detector.py Normal file
View file

@ -0,0 +1,692 @@
"""Universal project detection engine for Codeflash.
This module provides a single detection engine that works for all supported languages,
consolidating detection logic from various parts of the codebase.
Usage:
from codeflash.setup import detect_project
detected = detect_project()
print(f"Language: {detected.language}")
print(f"Module root: {detected.module_root}")
print(f"Test runner: {detected.test_runner}")
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import tomlkit
@dataclass
class DetectedProject:
"""Result of project auto-detection.
All paths are absolute. The confidence score indicates how certain
we are about the detection (0.0 = guessing, 1.0 = certain).
"""
# Core detection results
language: str # "python" | "javascript" | "typescript"
project_root: Path
module_root: Path
tests_root: Path | None
# Tooling detection
test_runner: str # "pytest" | "jest" | "vitest" | "mocha"
formatter_cmds: list[str]
# Ignore paths (absolute paths to ignore)
ignore_paths: list[Path] = field(default_factory=list)
# Confidence score for the detection (0.0 - 1.0)
confidence: float = 0.8
# Detection details (for debugging/display)
detection_details: dict[str, str] = field(default_factory=dict)
def to_display_dict(self) -> dict[str, str]:
"""Convert to dictionary for display purposes."""
formatter_display = self.formatter_cmds[0] if self.formatter_cmds else "none detected"
if len(self.formatter_cmds) > 1:
formatter_display += f" (+{len(self.formatter_cmds) - 1} more)"
ignore_display = ", ".join(p.name for p in self.ignore_paths[:3])
if len(self.ignore_paths) > 3:
ignore_display += f" (+{len(self.ignore_paths) - 3} more)"
return {
"Language": self.language.capitalize(),
"Module root": str(self.module_root.relative_to(self.project_root))
if self.module_root != self.project_root
else ".",
"Tests root": str(self.tests_root.relative_to(self.project_root)) if self.tests_root else "not detected",
"Test runner": self.test_runner,
"Formatter": formatter_display or "none",
"Ignoring": ignore_display or "defaults only",
}
def detect_project(path: Path | None = None) -> DetectedProject:
"""Auto-detect all project settings.
This is the main entry point for project detection. It finds the project root,
detects the language, and auto-detects all configuration values.
Args:
path: Starting path for detection. Defaults to current working directory.
Returns:
DetectedProject with all detected settings.
Raises:
ValueError: If no valid project can be detected.
"""
start_path = path or Path.cwd()
detection_details: dict[str, str] = {}
# Step 1: Find project root
project_root = _find_project_root(start_path)
if project_root is None:
# No project root found, use start_path
project_root = start_path
detection_details["project_root"] = "using current directory (no markers found)"
else:
detection_details["project_root"] = f"found at {project_root}"
# Step 2: Detect language
language, lang_confidence, lang_detail = _detect_language(project_root)
detection_details["language"] = lang_detail
# Step 3: Detect module root
module_root, module_detail = _detect_module_root(project_root, language)
detection_details["module_root"] = module_detail
# Step 4: Detect tests root
tests_root, tests_detail = _detect_tests_root(project_root, language)
detection_details["tests_root"] = tests_detail
# Step 5: Detect test runner
test_runner, runner_detail = _detect_test_runner(project_root, language)
detection_details["test_runner"] = runner_detail
# Step 6: Detect formatter
formatter_cmds, formatter_detail = _detect_formatter(project_root, language)
detection_details["formatter"] = formatter_detail
# Step 7: Detect ignore paths
ignore_paths, ignore_detail = _detect_ignore_paths(project_root, language)
detection_details["ignore_paths"] = ignore_detail
# Calculate overall confidence
confidence = lang_confidence * 0.4 + 0.6 # Language detection is 40% of confidence
return DetectedProject(
language=language,
project_root=project_root,
module_root=module_root,
tests_root=tests_root,
test_runner=test_runner,
formatter_cmds=formatter_cmds,
ignore_paths=ignore_paths,
confidence=confidence,
detection_details=detection_details,
)
def _find_project_root(start_path: Path) -> Path | None:
"""Find the project root by walking up the directory tree.
Looks for:
- .git directory (git repository root)
- pyproject.toml (Python project)
- package.json (JavaScript/TypeScript project)
- Cargo.toml (Rust project - future)
Args:
start_path: Starting directory for search.
Returns:
Path to project root, or None if not found.
"""
current = start_path.resolve()
while current != current.parent:
# Check for project markers
markers = [".git", "pyproject.toml", "package.json", "Cargo.toml"]
for marker in markers:
if (current / marker).exists():
return current
current = current.parent
return None
def _detect_language(project_root: Path) -> tuple[str, float, str]:
"""Detect the primary programming language of the project.
Detection priority:
1. tsconfig.json TypeScript (high confidence)
2. pyproject.toml or setup.py Python (high confidence)
3. package.json JavaScript (medium confidence)
4. File extension counting best guess (low confidence)
Args:
project_root: Root directory of the project.
Returns:
Tuple of (language, confidence, detail_string).
"""
has_tsconfig = (project_root / "tsconfig.json").exists()
has_pyproject = (project_root / "pyproject.toml").exists()
has_setup_py = (project_root / "setup.py").exists()
has_package_json = (project_root / "package.json").exists()
# TypeScript (tsconfig.json is definitive)
if has_tsconfig:
return "typescript", 1.0, "tsconfig.json found"
# Python (pyproject.toml or setup.py)
if has_pyproject or has_setup_py:
marker = "pyproject.toml" if has_pyproject else "setup.py"
# Check if it's also a JS project (monorepo)
if has_package_json:
# Count files to determine primary language
py_count = len(list(project_root.rglob("*.py")))
js_count = len(list(project_root.rglob("*.js"))) + len(list(project_root.rglob("*.ts")))
if js_count > py_count * 2: # JS files significantly outnumber Python
return "javascript", 0.7, "package.json found (more JS files than Python)"
return "python", 1.0, f"{marker} found"
# JavaScript (package.json without Python markers)
if has_package_json:
return "javascript", 0.9, "package.json found"
# Fall back to file extension counting
py_count = len(list(project_root.rglob("*.py")))
js_count = len(list(project_root.rglob("*.js")))
ts_count = len(list(project_root.rglob("*.ts")))
if ts_count > 0:
return "typescript", 0.5, f"found {ts_count} .ts files"
if js_count > py_count:
return "javascript", 0.5, f"found {js_count} .js files"
if py_count > 0:
return "python", 0.5, f"found {py_count} .py files"
# Default to Python
return "python", 0.3, "defaulting to Python"
def _detect_module_root(project_root: Path, language: str) -> tuple[Path, str]:
"""Detect the module/source root directory.
Args:
project_root: Root directory of the project.
language: Detected language.
Returns:
Tuple of (module_root_path, detail_string).
"""
if language in ("javascript", "typescript"):
return _detect_js_module_root(project_root)
return _detect_python_module_root(project_root)
def _detect_python_module_root(project_root: Path) -> tuple[Path, str]:
"""Detect Python module root.
Priority:
1. pyproject.toml [tool.poetry.name] or [project.name]
2. src/ directory with __init__.py
3. Directory with __init__.py matching project name
4. src/ directory (even without __init__.py)
5. Project root
"""
# Try to get project name from pyproject.toml
pyproject_path = project_root / "pyproject.toml"
project_name = None
if pyproject_path.exists():
try:
with pyproject_path.open("rb") as f:
data = tomlkit.parse(f.read())
# Try poetry name
project_name = data.get("tool", {}).get("poetry", {}).get("name")
# Try standard project name
if not project_name:
project_name = data.get("project", {}).get("name")
except Exception:
pass
# Check for src layout
src_dir = project_root / "src"
if src_dir.is_dir():
# Check for package inside src
if project_name:
pkg_dir = src_dir / project_name
if pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists():
return pkg_dir, f"src/{project_name}/ (from pyproject.toml name)"
# Check for any package in src
for child in src_dir.iterdir():
if child.is_dir() and (child / "__init__.py").exists():
return child, f"src/{child.name}/ (first package in src)"
# Use src/ even without __init__.py
return src_dir, "src/ directory"
# Check for package at project root
if project_name:
pkg_dir = project_root / project_name
if pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists():
return pkg_dir, f"{project_name}/ (from pyproject.toml name)"
# Look for any directory with __init__.py at project root
for child in project_root.iterdir():
if (
child.is_dir()
and not child.name.startswith(".")
and child.name not in ("tests", "test", "docs", "venv", ".venv", "env", "node_modules")
):
if (child / "__init__.py").exists():
return child, f"{child.name}/ (has __init__.py)"
# Default to project root
return project_root, "project root (no package structure detected)"
def _detect_js_module_root(project_root: Path) -> tuple[Path, str]:
"""Detect JavaScript/TypeScript module root.
Priority:
1. package.json "exports" field
2. package.json "module" field (ESM)
3. package.json "main" field (CJS)
4. src/ directory
5. lib/ directory
6. Project root
"""
package_json_path = project_root / "package.json"
package_data: dict[str, Any] = {}
if package_json_path.exists():
try:
with package_json_path.open(encoding="utf8") as f:
package_data = json.load(f)
except (json.JSONDecodeError, OSError):
pass
# Check exports field (modern Node.js)
exports = package_data.get("exports")
if exports:
entry_path = _extract_entry_path(exports)
if entry_path:
parent = Path(entry_path).parent
if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir():
return project_root / parent, f'{parent.as_posix()}/ (from package.json "exports")'
# Check module field (ESM)
module_field = package_data.get("module")
if module_field and isinstance(module_field, str):
parent = Path(module_field).parent
if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir():
return project_root / parent, f'{parent.as_posix()}/ (from package.json "module")'
# Check main field (CJS)
main_field = package_data.get("main")
if main_field and isinstance(main_field, str):
parent = Path(main_field).parent
if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir():
return project_root / parent, f'{parent.as_posix()}/ (from package.json "main")'
# Check for common source directories
for src_dir in ["src", "lib", "source"]:
if (project_root / src_dir).is_dir():
return project_root / src_dir, f"{src_dir}/ directory"
# Default to project root
return project_root, "project root"
def _extract_entry_path(exports: Any) -> str | None:
"""Extract entry path from package.json exports field."""
if isinstance(exports, str):
return exports
if isinstance(exports, dict):
# Handle {"." : "./src/index.js"} or {".": {"import": "./src/index.js"}}
main_export = exports.get(".") or exports.get("import") or exports.get("default")
if isinstance(main_export, str):
return main_export
if isinstance(main_export, dict):
return main_export.get("import") or main_export.get("default") or main_export.get("require")
return None
def _detect_tests_root(project_root: Path, language: str) -> tuple[Path | None, str]:
"""Detect the tests directory.
Common patterns:
- tests/ or test/
- __tests__/ (JavaScript)
- spec/ (Ruby/JavaScript)
"""
# Common test directory names
test_dirs = ["tests", "test", "__tests__", "spec"]
for test_dir in test_dirs:
test_path = project_root / test_dir
if test_path.is_dir():
return test_path, f"{test_dir}/ directory"
# For Python, check if tests are alongside source
if language == "python":
# Look for test_*.py files in project root
test_files = list(project_root.glob("test_*.py"))
if test_files:
return project_root, "test files in project root"
# For JS/TS, check for *.test.js or *.spec.js files
if language in ("javascript", "typescript"):
test_patterns = ["*.test.js", "*.test.ts", "*.spec.js", "*.spec.ts"]
for pattern in test_patterns:
test_files = list(project_root.rglob(pattern))
if test_files:
# Find common parent
return project_root, f"found {pattern} files"
return None, "not detected"
def _detect_test_runner(project_root: Path, language: str) -> tuple[str, str]:
"""Detect the test runner.
Python: pytest > unittest
JavaScript: vitest > jest > mocha
"""
if language in ("javascript", "typescript"):
return _detect_js_test_runner(project_root)
return _detect_python_test_runner(project_root)
def _detect_python_test_runner(project_root: Path) -> tuple[str, str]:
"""Detect Python test runner."""
# Check for pytest markers
pytest_markers = ["pytest.ini", "pyproject.toml", "conftest.py", "setup.cfg"]
for marker in pytest_markers:
marker_path = project_root / marker
if marker_path.exists():
if marker == "pyproject.toml":
# Check for [tool.pytest] section
try:
with marker_path.open("rb") as f:
data = tomlkit.parse(f.read())
if "tool" in data and "pytest" in data["tool"]:
return "pytest", "pyproject.toml [tool.pytest]"
except Exception:
pass
elif marker == "conftest.py":
return "pytest", "conftest.py found"
elif marker in ("pytest.ini", "setup.cfg"):
# Check for pytest section in setup.cfg
if marker == "setup.cfg":
try:
content = marker_path.read_text(encoding="utf8")
if "[tool:pytest]" in content or "[pytest]" in content:
return "pytest", "setup.cfg [pytest]"
except Exception:
pass
else:
return "pytest", "pytest.ini found"
# Default to pytest (most common)
return "pytest", "default"
def _detect_js_test_runner(project_root: Path) -> tuple[str, str]:
"""Detect JavaScript test runner."""
package_json_path = project_root / "package.json"
if not package_json_path.exists():
return "jest", "default (no package.json)"
try:
with package_json_path.open(encoding="utf8") as f:
package_data = json.load(f)
except (json.JSONDecodeError, OSError):
return "jest", "default (invalid package.json)"
runners = ["vitest", "jest", "mocha"]
dev_deps = package_data.get("devDependencies", {})
deps = package_data.get("dependencies", {})
all_deps = {**deps, **dev_deps}
# Check dependencies (order matters - prefer more modern runners)
for runner in runners:
if runner in all_deps:
return runner, "from devDependencies"
# Parse scripts.test for hints
scripts = package_data.get("scripts", {})
test_script = scripts.get("test", "")
if isinstance(test_script, str):
test_lower = test_script.lower()
for runner in runners:
if runner in test_lower:
return runner, "from scripts.test"
# Check for config files
config_files = {
"vitest": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
"jest": ["jest.config.js", "jest.config.ts", "jest.config.mjs", "jest.config.json"],
"mocha": [".mocharc.js", ".mocharc.json", ".mocharc.yaml"],
}
for runner, configs in config_files.items():
for config in configs:
if (project_root / config).exists():
return runner, f"{config} found"
return "jest", "default"
def _detect_formatter(project_root: Path, language: str) -> tuple[list[str], str]:
"""Detect code formatter.
Python: ruff > black
JavaScript: prettier > eslint --fix
"""
if language in ("javascript", "typescript"):
return _detect_js_formatter(project_root)
return _detect_python_formatter(project_root)
def _detect_python_formatter(project_root: Path) -> tuple[list[str], str]:
"""Detect Python formatter."""
pyproject_path = project_root / "pyproject.toml"
if pyproject_path.exists():
try:
with pyproject_path.open("rb") as f:
data = tomlkit.parse(f.read())
tool = data.get("tool", {})
# Check for ruff
if "ruff" in tool:
return ["ruff check --exit-zero --fix $file", "ruff format $file"], "from pyproject.toml [tool.ruff]"
# Check for black
if "black" in tool:
return ["black $file"], "from pyproject.toml [tool.black]"
except Exception:
pass
# Check for config files
if (project_root / "ruff.toml").exists() or (project_root / ".ruff.toml").exists():
return ["ruff check --exit-zero --fix $file", "ruff format $file"], "ruff.toml found"
if (project_root / ".black").exists() or (project_root / "pyproject.toml").exists():
# Default to black if pyproject.toml exists (common setup)
return ["black $file"], "default (black)"
return [], "none detected"
def _detect_js_formatter(project_root: Path) -> tuple[list[str], str]:
"""Detect JavaScript formatter."""
package_json_path = project_root / "package.json"
# Check for prettier config files
prettier_configs = [".prettierrc", ".prettierrc.js", ".prettierrc.json", "prettier.config.js"]
for config in prettier_configs:
if (project_root / config).exists():
return ["npx prettier --write $file"], f"{config} found"
# Check for eslint config files
eslint_configs = [".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js"]
for config in eslint_configs:
if (project_root / config).exists():
return ["npx eslint --fix $file"], f"{config} found"
# Check package.json dependencies
if package_json_path.exists():
try:
with package_json_path.open(encoding="utf8") as f:
package_data = json.load(f)
dev_deps = package_data.get("devDependencies", {})
deps = package_data.get("dependencies", {})
all_deps = {**deps, **dev_deps}
if "prettier" in all_deps:
return ["npx prettier --write $file"], "from devDependencies"
if "eslint" in all_deps:
return ["npx eslint --fix $file"], "from devDependencies"
except (json.JSONDecodeError, OSError):
pass
return [], "none detected"
def _detect_ignore_paths(project_root: Path, language: str) -> tuple[list[Path], str]:
"""Detect paths to ignore during optimization.
Sources:
1. .gitignore
2. Language-specific defaults
"""
ignore_paths: list[Path] = []
sources: list[str] = []
# Default ignore patterns by language
default_ignores: dict[str, list[str]] = {
"python": [
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
"venv",
".venv",
"env",
".env",
"dist",
"build",
"*.egg-info",
".tox",
".nox",
"htmlcov",
".coverage",
],
"javascript": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage", ".cache"],
"typescript": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage", ".cache"],
}
# Add default ignores
for pattern in default_ignores.get(language, []):
path = project_root / pattern.replace("*", "")
if path.exists():
ignore_paths.append(path)
if ignore_paths:
sources.append("defaults")
# Parse .gitignore
gitignore_path = project_root / ".gitignore"
if gitignore_path.exists():
try:
content = gitignore_path.read_text(encoding="utf8")
for line in content.splitlines():
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith("#"):
continue
# Skip negation patterns
if line.startswith("!"):
continue
# Convert gitignore pattern to path
pattern = line.rstrip("/").lstrip("/")
# Skip complex patterns for now
if "*" in pattern or "?" in pattern:
continue
path = project_root / pattern
if path.exists() and path not in ignore_paths:
ignore_paths.append(path)
if ".gitignore" not in sources:
sources.append(".gitignore")
except Exception:
pass
detail = " + ".join(sources) if sources else "none"
return ignore_paths, detail
def has_existing_config(project_root: Path) -> tuple[bool, str | None]:
"""Check if project has existing Codeflash configuration.
Args:
project_root: Root directory of the project.
Returns:
Tuple of (has_config, config_file_type).
config_file_type is "pyproject.toml", "package.json", or None.
"""
# Check pyproject.toml
pyproject_path = project_root / "pyproject.toml"
if pyproject_path.exists():
try:
with pyproject_path.open("rb") as f:
data = tomlkit.parse(f.read())
if "tool" in data and "codeflash" in data["tool"]:
return True, "pyproject.toml"
except Exception:
pass
# Check package.json
package_json_path = project_root / "package.json"
if package_json_path.exists():
try:
with package_json_path.open(encoding="utf8") as f:
data = json.load(f)
if "codeflash" in data:
return True, "package.json"
except Exception:
pass
return False, None

View file

@ -0,0 +1,294 @@
"""First-run experience for Codeflash.
This module handles the seamless first-run experience:
1. Auto-detect project settings
2. Display detected settings
3. Quick confirmation
4. API key setup
5. Save config and continue
Usage:
from codeflash.setup.first_run import handle_first_run, is_first_run
if is_first_run():
args = handle_first_run(args)
"""
from __future__ import annotations
import os
import sys
from typing import TYPE_CHECKING
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from codeflash.cli_cmds.console import console
from codeflash.setup.config_writer import write_config
from codeflash.setup.detector import detect_project, has_existing_config
if TYPE_CHECKING:
from argparse import Namespace
from pathlib import Path
def is_first_run(project_root: Path | None = None) -> bool:
"""Check if this is the first run (no config exists).
Args:
project_root: Project root to check. Defaults to auto-detect.
Returns:
True if no Codeflash config exists.
"""
if project_root is None:
try:
detected = detect_project()
project_root = detected.project_root
except Exception:
return True
has_config, _ = has_existing_config(project_root)
return not has_config
def handle_first_run(
args: Namespace | None = None, skip_confirm: bool = False, skip_api_key: bool = False
) -> Namespace | None:
"""Handle the first-run experience with auto-detection and quick confirm.
This is the main entry point for the frictionless setup experience.
Args:
args: Optional CLI args namespace to update.
skip_confirm: Skip confirmation prompt (--yes flag).
skip_api_key: Skip API key prompt.
Returns:
Updated args namespace with detected settings, or None if user cancelled.
"""
from argparse import Namespace
# Auto-detect project
try:
detected = detect_project()
except Exception as e:
_show_detection_error(str(e))
return None
# Show welcome message
_show_welcome()
# Show detected settings
_show_detected_settings(detected)
# Get user confirmation
if not skip_confirm:
choice = _prompt_confirmation()
if choice == "n":
_show_cancelled()
return None
if choice == "customize":
# TODO: Implement customize flow (redirect to codeflash init)
console.print("\n💡 Run [cyan]codeflash init[/cyan] for full customization.\n")
return None
# Handle API key
if not skip_api_key:
api_key_ok = _handle_api_key()
if not api_key_ok:
return None
# Save config
success, message = write_config(detected)
if success:
console.print(f"\n{message}\n")
else:
console.print(f"\n⚠️ {message}\n")
console.print("Continuing with detected settings (not saved).\n")
# Create/update args namespace
if args is None:
args = Namespace()
# Populate args with detected values
args.module_root = str(detected.module_root)
args.tests_root = str(detected.tests_root) if detected.tests_root else None
args.project_root = str(detected.project_root)
args.formatter_cmds = detected.formatter_cmds
args.ignore_paths = [str(p) for p in detected.ignore_paths]
args.pytest_cmd = detected.test_runner
args.language = detected.language
# Set defaults for other common args
if not hasattr(args, "disable_telemetry"):
args.disable_telemetry = False
if not hasattr(args, "git_remote"):
args.git_remote = "origin"
return args
def _show_welcome() -> None:
"""Show welcome message for first-time users."""
welcome_panel = Panel(
Text(
"⚡ Welcome to Codeflash!\n\nI've auto-detected your project settings.\nThis will only take a moment.",
style="bold cyan",
justify="center",
),
title="🚀 First-Time Setup",
border_style="bright_cyan",
padding=(1, 2),
)
console.print(welcome_panel)
console.print()
def _show_detected_settings(detected: detect_project) -> None:
"""Display detected settings in a nice table."""
from codeflash.setup.detector import DetectedProject
if not isinstance(detected, DetectedProject):
return
# Create settings table
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Setting", style="cyan", width=15)
table.add_column("Value", style="green")
table.add_column("Source", style="dim")
display_dict = detected.to_display_dict()
details = detected.detection_details
for key, value in display_dict.items():
source = details.get(key.lower().replace(" ", "_"), "")
# Truncate long sources
if len(source) > 30:
source = source[:27] + "..."
table.add_row(key, value, f"({source})" if source else "")
settings_panel = Panel(table, title="🔍 Auto-Detected Settings", border_style="bright_blue", padding=(1, 2))
console.print(settings_panel)
console.print()
def _prompt_confirmation() -> str:
"""Prompt user for confirmation.
Returns:
"y" for yes, "n" for no, "customize" for customization.
"""
# Check if we're in a non-interactive environment
if not sys.stdin.isatty():
console.print("⚠️ Non-interactive environment detected. Use --yes to skip confirmation.")
return "n"
console.print("? [bold]Proceed with these settings?[/bold]")
console.print(" [green]Y[/green] - Yes, save and continue")
console.print(" [yellow]n[/yellow] - No, cancel")
console.print(" [cyan]c[/cyan] - Customize (run full setup)")
console.print()
try:
choice = console.input("[bold]Your choice[/bold] [green][Y][/green]/n/c: ").strip().lower()
except (KeyboardInterrupt, EOFError):
return "n"
if choice in ("", "y", "yes"):
return "y"
if choice in ("c", "customize"):
return "customize"
return "n"
def _handle_api_key() -> bool:
"""Handle API key setup if not already configured.
Returns:
True if API key is available, False if user cancelled.
"""
from codeflash.code_utils.env_utils import get_codeflash_api_key
# Check for existing API key
try:
existing_key = get_codeflash_api_key()
if existing_key:
display_key = f"{existing_key[:3]}****{existing_key[-4:]}"
console.print(f"✅ Found API key: [green]{display_key}[/green]\n")
return True
except OSError:
pass
# Prompt for API key
console.print("🔑 [bold]API Key Required[/bold]")
console.print(" Get your API key at: [cyan]https://app.codeflash.ai/app/apikeys[/cyan]\n")
try:
api_key = console.input(" Enter API key (or press Enter to open browser): ").strip()
except (KeyboardInterrupt, EOFError):
return False
if not api_key:
# Open browser
import click
click.launch("https://app.codeflash.ai/app/apikeys")
console.print("\n Opening browser...")
try:
api_key = console.input(" Enter API key: ").strip()
except (KeyboardInterrupt, EOFError):
return False
if not api_key:
console.print("\n⚠️ API key required. Run [cyan]codeflash init[/cyan] to set up.\n")
return False
if not api_key.startswith("cf-"):
console.print("\n⚠️ Invalid API key format. Should start with 'cf-'.\n")
return False
# Save API key to environment
os.environ["CODEFLASH_API_KEY"] = api_key
# Try to save to shell rc
try:
from codeflash.code_utils.shell_utils import save_api_key_to_rc
from codeflash.either import is_successful
result = save_api_key_to_rc(api_key)
if is_successful(result):
console.print(f"\n✅ API key saved. {result.unwrap()}\n")
else:
console.print(f"\n⚠️ Could not save to shell: {result.failure()}")
console.print(" API key set for this session only.\n")
except Exception:
console.print("\n✅ API key set for this session.\n")
return True
def _show_detection_error(error: str) -> None:
"""Show error message when detection fails."""
error_panel = Panel(
Text(
f"❌ Could not auto-detect project settings.\n\n"
f"Error: {error}\n\n"
"Please run [cyan]codeflash init[/cyan] for manual setup.",
style="red",
),
title="⚠️ Detection Failed",
border_style="red",
padding=(1, 2),
)
console.print(error_panel)
def _show_cancelled() -> None:
"""Show cancellation message."""
console.print("\n⏹️ Setup cancelled. Run [cyan]codeflash init[/cyan] when ready.\n")

View file

@ -1,12 +1,12 @@
{
"name": "codeflash",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeflash",
"version": "0.3.0",
"version": "0.4.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "codeflash",
"version": "0.3.1",
"version": "0.4.0",
"description": "Codeflash - AI-powered code optimization for JavaScript and TypeScript",
"main": "runtime/index.js",
"types": "runtime/index.d.ts",

View file

@ -0,0 +1 @@
# Tests for the codeflash.setup module

View file

@ -0,0 +1,395 @@
"""Tests for config schema and config writer."""
import json
import tomlkit
from codeflash.setup.config_schema import CodeflashConfig
from codeflash.setup.config_writer import (
_write_package_json,
_write_pyproject_toml,
create_pyproject_toml,
remove_config,
write_config,
)
from codeflash.setup.detector import detect_project
class TestCodeflashConfig:
"""Tests for CodeflashConfig Pydantic model."""
def test_default_values(self):
"""Should have sensible defaults."""
config = CodeflashConfig(language="python")
assert config.language == "python"
assert config.module_root == "."
assert config.tests_root is None
assert config.formatter_cmds == []
assert config.git_remote == "origin"
assert config.disable_telemetry is False
def test_all_fields(self):
"""Should accept all fields."""
config = CodeflashConfig(
language="javascript",
module_root="src",
tests_root="tests",
test_runner="jest",
formatter_cmds=["npx prettier --write $file"],
ignore_paths=["dist", "node_modules"],
benchmarks_root="benchmarks",
git_remote="upstream",
disable_telemetry=True,
)
assert config.language == "javascript"
assert config.module_root == "src"
assert config.tests_root == "tests"
assert config.test_runner == "jest"
assert config.formatter_cmds == ["npx prettier --write $file"]
assert config.ignore_paths == ["dist", "node_modules"]
assert config.git_remote == "upstream"
assert config.disable_telemetry is True
def test_to_pyproject_dict(self):
"""Should convert to pyproject.toml format with kebab-case."""
config = CodeflashConfig(
language="python",
module_root="codeflash",
tests_root="tests",
formatter_cmds=["ruff format $file"],
ignore_paths=["dist"],
)
result = config.to_pyproject_dict()
assert result["module-root"] == "codeflash"
assert result["tests-root"] == "tests"
assert result["formatter-cmds"] == ["ruff format $file"]
assert result["ignore-paths"] == ["dist"]
# Should not include default values
assert "git-remote" not in result
assert "disable-telemetry" not in result
def test_to_pyproject_dict_minimal(self):
"""Should only include non-default values."""
config = CodeflashConfig(
language="python",
module_root="src",
)
result = config.to_pyproject_dict()
assert "module-root" in result
# Empty formatter should result in "disabled"
assert result.get("formatter-cmds") == ["disabled"]
def test_to_package_json_dict(self):
"""Should convert to package.json format with camelCase."""
config = CodeflashConfig(
language="javascript",
module_root="lib", # Non-default
formatter_cmds=["npx prettier --write $file"],
ignore_paths=["dist"],
disable_telemetry=True,
)
result = config.to_package_json_dict()
assert result["moduleRoot"] == "lib"
assert result["formatterCmds"] == ["npx prettier --write $file"]
assert result["ignorePaths"] == ["dist"]
assert result["disableTelemetry"] is True
# Should not include default values
assert "gitRemote" not in result
def test_to_package_json_dict_minimal(self):
"""Should be empty when all values are defaults."""
config = CodeflashConfig(
language="javascript",
module_root="src", # Default for JS
)
result = config.to_package_json_dict()
# src is a default, should not be included
assert "moduleRoot" not in result
def test_from_detected_project(self, tmp_path):
"""Should create config from DetectedProject."""
# Create a simple Python project
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "test").mkdir()
(tmp_path / "test" / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
detected = detect_project(tmp_path)
config = CodeflashConfig.from_detected_project(detected)
assert config.language == detected.language
assert config.test_runner == detected.test_runner
def test_from_pyproject_dict(self):
"""Should create config from pyproject.toml dict."""
data = {
"module-root": "src",
"tests-root": "tests",
"formatter-cmds": ["black $file"],
"disable-telemetry": True,
}
config = CodeflashConfig.from_pyproject_dict(data)
assert config.module_root == "src"
assert config.tests_root == "tests"
assert config.formatter_cmds == ["black $file"]
assert config.disable_telemetry is True
assert config.language == "python" # Default for pyproject
def test_from_package_json_dict(self):
"""Should create config from package.json dict."""
data = {
"moduleRoot": "lib",
"formatterCmds": ["npx prettier --write $file"],
"disableTelemetry": True,
}
config = CodeflashConfig.from_package_json_dict(data)
assert config.module_root == "lib"
assert config.formatter_cmds == ["npx prettier --write $file"]
assert config.disable_telemetry is True
assert config.language == "javascript" # Default for package.json
class TestWritePyprojectToml:
"""Tests for writing to pyproject.toml."""
def test_creates_new_pyproject(self, tmp_path):
"""Should create pyproject.toml if it doesn't exist."""
config = CodeflashConfig(
language="python",
module_root="src",
tests_root="tests",
)
success, message = _write_pyproject_toml(tmp_path, config)
assert success is True
assert (tmp_path / "pyproject.toml").exists()
# Verify content
content = (tmp_path / "pyproject.toml").read_text()
data = tomlkit.parse(content)
assert "tool" in data
assert "codeflash" in data["tool"]
assert data["tool"]["codeflash"]["module-root"] == "src"
def test_preserves_existing_content(self, tmp_path):
"""Should preserve existing pyproject.toml content."""
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "myapp"\nversion = "1.0.0"\n\n[tool.ruff]\nline-length = 120'
)
config = CodeflashConfig(
language="python",
module_root="src",
)
success, message = _write_pyproject_toml(tmp_path, config)
assert success is True
# Verify existing content preserved
content = (tmp_path / "pyproject.toml").read_text()
data = tomlkit.parse(content)
assert data["project"]["name"] == "myapp"
assert data["tool"]["ruff"]["line-length"] == 120
assert data["tool"]["codeflash"]["module-root"] == "src"
def test_updates_existing_codeflash_section(self, tmp_path):
"""Should update existing codeflash section."""
(tmp_path / "pyproject.toml").write_text(
'[tool.codeflash]\nmodule-root = "old"\ntests-root = "old_tests"'
)
config = CodeflashConfig(
language="python",
module_root="new",
tests_root="new_tests",
)
success, message = _write_pyproject_toml(tmp_path, config)
assert success is True
content = (tmp_path / "pyproject.toml").read_text()
data = tomlkit.parse(content)
assert data["tool"]["codeflash"]["module-root"] == "new"
assert data["tool"]["codeflash"]["tests-root"] == "new_tests"
class TestWritePackageJson:
"""Tests for writing to package.json."""
def test_adds_codeflash_section(self, tmp_path):
"""Should add codeflash section to package.json."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"version": "1.0.0"
}, indent=2))
config = CodeflashConfig(
language="javascript",
module_root="lib",
formatter_cmds=["npx prettier --write $file"],
)
success, message = _write_package_json(tmp_path, config)
assert success is True
# Verify content
with (tmp_path / "package.json").open() as f:
data = json.load(f)
assert data["name"] == "myapp" # Preserved
assert "codeflash" in data
assert data["codeflash"]["moduleRoot"] == "lib"
def test_preserves_existing_content(self, tmp_path):
"""Should preserve existing package.json content."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"dependencies": {"lodash": "^4.17.0"},
"devDependencies": {"jest": "^29.0.0"}
}, indent=2))
config = CodeflashConfig(
language="javascript",
module_root="lib",
)
success, message = _write_package_json(tmp_path, config)
assert success is True
with (tmp_path / "package.json").open() as f:
data = json.load(f)
assert data["dependencies"]["lodash"] == "^4.17.0"
assert data["devDependencies"]["jest"] == "^29.0.0"
def test_removes_empty_codeflash_section(self, tmp_path):
"""Should remove codeflash section if all defaults."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"codeflash": {"moduleRoot": "old"}
}, indent=2))
# Config with all defaults - should result in empty dict
config = CodeflashConfig(
language="javascript",
module_root="src", # Default
)
success, message = _write_package_json(tmp_path, config)
assert success is True
with (tmp_path / "package.json").open() as f:
data = json.load(f)
# Empty codeflash section should be removed
assert "codeflash" not in data
def test_fails_if_no_package_json(self, tmp_path):
"""Should fail if package.json doesn't exist."""
config = CodeflashConfig(language="javascript")
success, message = _write_package_json(tmp_path, config)
assert success is False
assert message == f"No package.json found at {tmp_path}"
class TestWriteConfig:
"""Tests for the unified write_config function."""
def test_writes_to_pyproject_for_python(self, tmp_path):
"""Should write to pyproject.toml for Python projects."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "src").mkdir()
(tmp_path / "src" / "__init__.py").write_text("")
detected = detect_project(tmp_path)
success, message = write_config(detected)
assert success is True
assert message == f"Config saved to {tmp_path / 'pyproject.toml'}"
def test_writes_to_package_json_for_js(self, tmp_path):
"""Should write to package.json for JavaScript projects."""
(tmp_path / "package.json").write_text('{"name": "test"}')
(tmp_path / "src").mkdir()
detected = detect_project(tmp_path)
success, message = write_config(detected)
assert success is True
class TestRemoveConfig:
"""Tests for remove_config function."""
def test_removes_from_pyproject(self, tmp_path):
"""Should remove codeflash section from pyproject.toml."""
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "test"\n\n[tool.codeflash]\nmodule-root = "src"'
)
success, message = remove_config(tmp_path, "python")
assert success is True
content = (tmp_path / "pyproject.toml").read_text()
data = tomlkit.parse(content)
assert data["project"]["name"] == "test" # Preserved
assert "codeflash" not in data.get("tool", {})
def test_removes_from_package_json(self, tmp_path):
"""Should remove codeflash section from package.json."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "test",
"codeflash": {"moduleRoot": "src"}
}, indent=2))
success, message = remove_config(tmp_path, "javascript")
assert success is True
with (tmp_path / "package.json").open() as f:
data = json.load(f)
assert data["name"] == "test" # Preserved
assert "codeflash" not in data
class TestCreatePyprojectToml:
"""Tests for create_pyproject_toml function."""
def test_creates_minimal_pyproject(self, tmp_path):
"""Should create minimal pyproject.toml."""
success, message = create_pyproject_toml(tmp_path)
assert success is True
assert (tmp_path / "pyproject.toml").exists()
content = (tmp_path / "pyproject.toml").read_text()
assert "codeflash" in content.lower()
def test_fails_if_already_exists(self, tmp_path):
"""Should fail if pyproject.toml already exists."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
success, message = create_pyproject_toml(tmp_path)
assert success is False
assert message == f"pyproject.toml already exists at {tmp_path / 'pyproject.toml'}"

View file

@ -0,0 +1,381 @@
"""Tests for the universal project detector."""
import json
from codeflash.setup.detector import (
_detect_js_formatter,
_detect_js_module_root,
_detect_js_test_runner,
_detect_language,
_detect_python_formatter,
_detect_python_module_root,
_detect_python_test_runner,
_detect_tests_root,
_find_project_root,
detect_project,
has_existing_config,
)
class TestFindProjectRoot:
"""Tests for _find_project_root function."""
def test_finds_git_directory(self, tmp_path):
"""Should find project root by .git directory."""
(tmp_path / ".git").mkdir()
subdir = tmp_path / "src" / "deep"
subdir.mkdir(parents=True)
result = _find_project_root(subdir)
assert result == tmp_path
def test_finds_pyproject_toml(self, tmp_path):
"""Should find project root by pyproject.toml."""
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'test'")
subdir = tmp_path / "src"
subdir.mkdir()
result = _find_project_root(subdir)
assert result == tmp_path
def test_finds_package_json(self, tmp_path):
"""Should find project root by package.json."""
(tmp_path / "package.json").write_text('{"name": "test"}')
subdir = tmp_path / "lib"
subdir.mkdir()
result = _find_project_root(subdir)
assert result == tmp_path
def test_returns_none_when_no_markers(self, tmp_path):
"""Should return None when no project markers found."""
subdir = tmp_path / "orphan"
subdir.mkdir()
result = _find_project_root(subdir)
# Will walk up to filesystem root and not find anything
assert result is None or result == tmp_path
class TestDetectLanguage:
"""Tests for _detect_language function."""
def test_detects_typescript_from_tsconfig(self, tmp_path):
"""Should detect TypeScript when tsconfig.json exists."""
(tmp_path / "tsconfig.json").write_text("{}")
(tmp_path / "package.json").write_text('{"name": "test"}')
lang, confidence, detail = _detect_language(tmp_path)
assert lang == "typescript"
assert confidence == 1.0
assert "tsconfig.json" in detail
def test_detects_python_from_pyproject(self, tmp_path):
"""Should detect Python when pyproject.toml exists."""
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'test'")
lang, confidence, detail = _detect_language(tmp_path)
assert lang == "python"
assert confidence == 1.0
assert "pyproject.toml" in detail
def test_detects_python_from_setup_py(self, tmp_path):
"""Should detect Python when setup.py exists."""
(tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()")
lang, confidence, detail = _detect_language(tmp_path)
assert lang == "python"
assert confidence == 1.0
assert "setup.py" in detail
def test_detects_javascript_from_package_json(self, tmp_path):
"""Should detect JavaScript when only package.json exists."""
(tmp_path / "package.json").write_text('{"name": "test"}')
lang, confidence, detail = _detect_language(tmp_path)
assert lang == "javascript"
assert confidence == 0.9
assert "package.json" in detail
def test_defaults_to_python(self, tmp_path):
"""Should default to Python when no markers found."""
lang, confidence, detail = _detect_language(tmp_path)
assert lang == "python"
assert confidence < 0.5 # Low confidence
class TestDetectModuleRoot:
"""Tests for module root detection."""
def test_python_detects_src_layout(self, tmp_path):
"""Should detect src/ layout for Python."""
src_dir = tmp_path / "src" / "mypackage"
src_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text("")
module_root, detail = _detect_python_module_root(tmp_path)
assert module_root == src_dir
assert module_root.name == "mypackage"
assert module_root.parent.name == "src"
def test_python_detects_package_at_root(self, tmp_path):
"""Should detect package at project root."""
pkg_dir = tmp_path / "mypackage"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text("")
module_root, detail = _detect_python_module_root(tmp_path)
assert module_root == pkg_dir
def test_python_uses_pyproject_name(self, tmp_path):
"""Should use project name from pyproject.toml."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"')
pkg_dir = tmp_path / "myapp"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text("")
module_root, detail = _detect_python_module_root(tmp_path)
assert module_root == pkg_dir
assert "pyproject.toml" in detail
def test_js_detects_from_exports(self, tmp_path):
"""Should detect module root from package.json exports."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "test",
"exports": {".": "./src/index.js"}
}))
(tmp_path / "src").mkdir()
module_root, detail = _detect_js_module_root(tmp_path)
assert module_root == tmp_path / "src"
assert "exports" in detail
def test_js_detects_src_convention(self, tmp_path):
"""Should detect src/ directory for JS."""
(tmp_path / "package.json").write_text('{"name": "test"}')
(tmp_path / "src").mkdir()
module_root, detail = _detect_js_module_root(tmp_path)
assert module_root == tmp_path / "src"
class TestDetectTestsRoot:
"""Tests for tests root detection."""
def test_detects_tests_directory(self, tmp_path):
"""Should detect tests/ directory."""
(tmp_path / "tests").mkdir()
tests_root, detail = _detect_tests_root(tmp_path, "python")
assert tests_root == tmp_path / "tests"
def test_detects_test_directory(self, tmp_path):
"""Should detect test/ directory."""
(tmp_path / "test").mkdir()
tests_root, detail = _detect_tests_root(tmp_path, "python")
assert tests_root == tmp_path / "test"
def test_detects_dunder_tests(self, tmp_path):
"""Should detect __tests__/ directory (JS convention)."""
(tmp_path / "__tests__").mkdir()
tests_root, detail = _detect_tests_root(tmp_path, "javascript")
assert tests_root == tmp_path / "__tests__"
def test_returns_none_when_not_found(self, tmp_path):
"""Should return None when no tests directory found."""
tests_root, detail = _detect_tests_root(tmp_path, "python")
assert tests_root is None
class TestDetectTestRunner:
"""Tests for test runner detection."""
def test_python_detects_pytest_from_ini(self, tmp_path):
"""Should detect pytest from pytest.ini."""
(tmp_path / "pytest.ini").write_text("[pytest]")
runner, detail = _detect_python_test_runner(tmp_path)
assert runner == "pytest"
def test_python_detects_pytest_from_conftest(self, tmp_path):
"""Should detect pytest from conftest.py."""
(tmp_path / "conftest.py").write_text("import pytest")
runner, detail = _detect_python_test_runner(tmp_path)
assert runner == "pytest"
def test_js_detects_jest_from_deps(self, tmp_path):
"""Should detect jest from devDependencies."""
(tmp_path / "package.json").write_text(json.dumps({
"devDependencies": {"jest": "^29.0.0"}
}))
runner, detail = _detect_js_test_runner(tmp_path)
assert runner == "jest"
def test_js_detects_vitest_from_deps(self, tmp_path):
"""Should detect vitest from devDependencies (preferred over jest)."""
(tmp_path / "package.json").write_text(json.dumps({
"devDependencies": {"vitest": "^1.0.0", "jest": "^29.0.0"}
}))
runner, detail = _detect_js_test_runner(tmp_path)
assert runner == "vitest"
def test_js_detects_from_config_file(self, tmp_path):
"""Should detect test runner from config file."""
(tmp_path / "package.json").write_text('{"name": "test"}')
(tmp_path / "vitest.config.js").write_text("export default {}")
runner, detail = _detect_js_test_runner(tmp_path)
assert runner == "vitest"
class TestDetectFormatter:
"""Tests for formatter detection."""
def test_python_detects_ruff(self, tmp_path):
"""Should detect ruff from pyproject.toml."""
(tmp_path / "pyproject.toml").write_text("[tool.ruff]\nline-length = 120")
formatter, detail = _detect_python_formatter(tmp_path)
assert any("ruff" in cmd for cmd in formatter)
def test_python_detects_black(self, tmp_path):
"""Should detect black from pyproject.toml."""
(tmp_path / "pyproject.toml").write_text("[tool.black]\nline-length = 88")
formatter, detail = _detect_python_formatter(tmp_path)
assert any("black" in cmd for cmd in formatter)
def test_js_detects_prettier(self, tmp_path):
"""Should detect prettier from config file."""
(tmp_path / "package.json").write_text('{"name": "test"}')
(tmp_path / ".prettierrc").write_text("{}")
formatter, detail = _detect_js_formatter(tmp_path)
assert any("prettier" in cmd for cmd in formatter)
def test_js_detects_prettier_from_deps(self, tmp_path):
"""Should detect prettier from devDependencies."""
(tmp_path / "package.json").write_text(json.dumps({
"devDependencies": {"prettier": "^3.0.0"}
}))
formatter, detail = _detect_js_formatter(tmp_path)
assert any("prettier" in cmd for cmd in formatter)
class TestDetectProject:
"""Integration tests for detect_project function."""
def test_detects_python_project(self, tmp_path):
"""Should correctly detect a Python project."""
# Create Python project structure
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "myapp"\n\n[tool.ruff]\nline-length = 120'
)
(tmp_path / "myapp").mkdir()
(tmp_path / "myapp" / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
(tmp_path / ".git").mkdir()
detected = detect_project(tmp_path)
assert detected.language == "python"
assert detected.project_root == tmp_path
assert detected.module_root == tmp_path / "myapp"
assert detected.tests_root == tmp_path / "tests"
assert detected.test_runner == "pytest"
assert any("ruff" in cmd for cmd in detected.formatter_cmds)
def test_detects_javascript_project(self, tmp_path):
"""Should correctly detect a JavaScript project."""
# Create JS project structure
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"devDependencies": {"jest": "^29.0.0", "prettier": "^3.0.0"}
}))
(tmp_path / "src").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / ".git").mkdir()
detected = detect_project(tmp_path)
assert detected.language == "javascript"
assert detected.project_root == tmp_path
assert detected.module_root == tmp_path / "src"
assert detected.tests_root == tmp_path / "tests"
assert detected.test_runner == "jest"
assert any("prettier" in cmd for cmd in detected.formatter_cmds)
def test_detects_typescript_project(self, tmp_path):
"""Should correctly detect a TypeScript project."""
# Create TS project structure
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"devDependencies": {"vitest": "^1.0.0", "typescript": "^5.0.0"}
}))
(tmp_path / "tsconfig.json").write_text("{}")
(tmp_path / "src").mkdir()
(tmp_path / ".git").mkdir()
detected = detect_project(tmp_path)
assert detected.language == "typescript"
assert detected.test_runner == "vitest"
def test_to_display_dict(self, tmp_path):
"""Should generate display dictionary correctly."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "test").mkdir()
(tmp_path / "test" / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
detected = detect_project(tmp_path)
display = detected.to_display_dict()
assert "Language" in display
assert "Module root" in display
assert "Test runner" in display
class TestHasExistingConfig:
"""Tests for has_existing_config function."""
def test_detects_pyproject_config(self, tmp_path):
"""Should detect config in pyproject.toml."""
(tmp_path / "pyproject.toml").write_text(
'[tool.codeflash]\nmodule-root = "src"'
)
has_config, config_type = has_existing_config(tmp_path)
assert has_config is True
assert config_type == "pyproject.toml"
def test_detects_package_json_config(self, tmp_path):
"""Should detect config in package.json."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "test",
"codeflash": {"moduleRoot": "src"}
}))
has_config, config_type = has_existing_config(tmp_path)
assert has_config is True
assert config_type == "package.json"
def test_returns_false_when_no_config(self, tmp_path):
"""Should return False when no codeflash config exists."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
has_config, config_type = has_existing_config(tmp_path)
assert has_config is False
assert config_type is None
def test_returns_false_for_empty_directory(self, tmp_path):
"""Should return False for empty directory."""
has_config, config_type = has_existing_config(tmp_path)
assert has_config is False
assert config_type is None

View file

@ -0,0 +1,909 @@
"""End-to-end tests for the setup flow.
These tests validate the complete setup experience across different:
- Languages (Python, JavaScript, TypeScript)
- Project structures (src/, flat, monorepo-like)
- Package managers (npm, yarn, pnpm, bun)
- Existing config scenarios
"""
import json
from argparse import Namespace
import pytest
import tomlkit
from codeflash.setup import (
CodeflashConfig,
detect_project,
handle_first_run,
has_existing_config,
is_first_run,
write_config,
)
# =============================================================================
# Fixtures for creating different project types
# =============================================================================
@pytest.fixture
def python_src_layout(tmp_path):
"""Create a Python project with src/ layout."""
# pyproject.toml with poetry
(tmp_path / "pyproject.toml").write_text("""
[tool.poetry]
name = "myapp"
version = "0.1.0"
[tool.ruff]
line-length = 120
[tool.pytest.ini_options]
testpaths = ["tests"]
""".strip())
# src/myapp package
src_dir = tmp_path / "src" / "myapp"
src_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text('__version__ = "0.1.0"')
(src_dir / "main.py").write_text("def main(): pass")
(src_dir / "utils.py").write_text("def helper(): pass")
# tests directory
tests_dir = tmp_path / "tests"
tests_dir.mkdir()
(tests_dir / "__init__.py").write_text("")
(tests_dir / "test_main.py").write_text("def test_main(): pass")
(tests_dir / "conftest.py").write_text("import pytest")
# .git directory
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def python_flat_layout(tmp_path):
"""Create a Python project with flat layout (package at root)."""
(tmp_path / "pyproject.toml").write_text("""
[project]
name = "myapp"
version = "0.1.0"
[tool.black]
line-length = 88
""".strip())
# Package at root
pkg_dir = tmp_path / "myapp"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text("")
(pkg_dir / "core.py").write_text("def process(): pass")
# Tests at root
tests_dir = tmp_path / "tests"
tests_dir.mkdir()
(tests_dir / "test_core.py").write_text("def test_process(): pass")
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def python_setup_py_project(tmp_path):
"""Create a Python project with setup.py (legacy)."""
(tmp_path / "setup.py").write_text("""
from setuptools import setup, find_packages
setup(
name="legacyapp",
version="1.0.0",
packages=find_packages(),
)
""".strip())
pkg_dir = tmp_path / "legacyapp"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def javascript_npm_project(tmp_path):
"""Create a JavaScript project with npm."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "my-js-app",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"test": "jest",
"lint": "eslint src/"
},
"devDependencies": {
"jest": "^29.7.0",
"prettier": "^3.0.0"
}
}, indent=2))
(tmp_path / "package-lock.json").write_text("{}")
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "index.js").write_text("module.exports = {}")
(src_dir / "utils.js").write_text("function helper() {}")
tests_dir = tmp_path / "tests"
tests_dir.mkdir()
(tests_dir / "index.test.js").write_text("test('works', () => {})")
(tmp_path / ".prettierrc").write_text("{}")
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def javascript_yarn_project(tmp_path):
"""Create a JavaScript project with yarn."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "yarn-app",
"version": "1.0.0",
"main": "lib/index.js",
"devDependencies": {
"jest": "^29.0.0",
"eslint": "^8.0.0"
}
}, indent=2))
(tmp_path / "yarn.lock").write_text("# yarn lockfile")
lib_dir = tmp_path / "lib"
lib_dir.mkdir()
(lib_dir / "index.js").write_text("")
(tmp_path / "__tests__").mkdir()
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def javascript_pnpm_project(tmp_path):
"""Create a JavaScript project with pnpm."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "pnpm-app",
"version": "1.0.0",
"exports": {
".": "./dist/index.js"
},
"devDependencies": {
"vitest": "^1.0.0"
}
}, indent=2))
(tmp_path / "pnpm-lock.yaml").write_text("lockfileVersion: 5.4")
(tmp_path / "dist").mkdir()
(tmp_path / "src").mkdir()
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def javascript_bun_project(tmp_path):
"""Create a JavaScript project with bun."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "bun-app",
"version": "1.0.0",
"module": "src/index.ts",
"devDependencies": {
"bun-types": "latest"
}
}, indent=2))
(tmp_path / "bun.lockb").write_bytes(b"bun lockfile")
(tmp_path / "src").mkdir()
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def typescript_project(tmp_path):
"""Create a TypeScript project."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "ts-app",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "vitest"
},
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^1.0.0",
"@types/node": "^20.0.0"
}
}, indent=2))
(tmp_path / "tsconfig.json").write_text(json.dumps({
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": True
},
"include": ["src/**/*"]
}, indent=2))
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "index.ts").write_text("export const main = () => {}")
(src_dir / "types.ts").write_text("export interface Config {}")
tests_dir = tmp_path / "tests"
tests_dir.mkdir()
(tests_dir / "index.test.ts").write_text("import { describe, it } from 'vitest'")
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def typescript_react_project(tmp_path):
"""Create a TypeScript React project (like Create React App)."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "react-app",
"version": "0.1.0",
"private": True,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"jest": "^29.0.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"@testing-library/react": "^14.0.0",
"typescript": "^5.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test"
}
}, indent=2))
(tmp_path / "tsconfig.json").write_text(json.dumps({
"compilerOptions": {
"target": "es5",
"lib": ["dom", "es2015"],
"jsx": "react-jsx"
}
}, indent=2))
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "App.tsx").write_text("export default function App() { return <div/>; }")
(src_dir / "index.tsx").write_text("import App from './App';")
(src_dir / "App.test.tsx").write_text("test('renders', () => {});")
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def project_with_existing_config(tmp_path):
"""Create a project with existing codeflash config."""
(tmp_path / "pyproject.toml").write_text("""
[project]
name = "configured-app"
[tool.codeflash]
module-root = "src"
tests-root = "tests"
formatter-cmds = ["black $file"]
""".strip())
(tmp_path / "src").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / ".git").mkdir()
return tmp_path
@pytest.fixture
def mixed_python_js_project(tmp_path):
"""Create a project with both Python and JS files (monorepo-like)."""
# Python backend
(tmp_path / "pyproject.toml").write_text("""
[project]
name = "fullstack-app"
[tool.codeflash]
module-root = "backend"
""".strip())
backend_dir = tmp_path / "backend"
backend_dir.mkdir()
(backend_dir / "__init__.py").write_text("")
(backend_dir / "api.py").write_text("def handler(): pass")
# JS frontend
frontend_dir = tmp_path / "frontend"
frontend_dir.mkdir()
(frontend_dir / "package.json").write_text(json.dumps({
"name": "frontend",
"devDependencies": {"jest": "^29.0.0"}
}))
(frontend_dir / "src").mkdir()
(frontend_dir / "src" / "app.js").write_text("")
(tmp_path / ".git").mkdir()
return tmp_path
# =============================================================================
# E2E Tests: Detection
# =============================================================================
class TestE2EDetection:
"""E2E tests for project detection across different setups."""
def test_python_src_layout_detection(self, python_src_layout):
"""Should correctly detect Python src/ layout project."""
detected = detect_project(python_src_layout)
assert detected.language == "python"
assert detected.project_root == python_src_layout
assert detected.module_root.name == "myapp"
assert detected.tests_root == python_src_layout / "tests"
assert detected.test_runner == "pytest"
assert any("ruff" in cmd for cmd in detected.formatter_cmds)
assert detected.confidence >= 0.9
def test_python_flat_layout_detection(self, python_flat_layout):
"""Should correctly detect Python flat layout project."""
detected = detect_project(python_flat_layout)
assert detected.language == "python"
assert detected.module_root.name == "myapp"
assert any("black" in cmd for cmd in detected.formatter_cmds)
def test_python_setup_py_detection(self, python_setup_py_project):
"""Should correctly detect legacy setup.py project."""
detected = detect_project(python_setup_py_project)
assert detected.language == "python"
assert detected.module_root.name == "legacyapp"
def test_javascript_npm_detection(self, javascript_npm_project):
"""Should correctly detect JavaScript npm project."""
detected = detect_project(javascript_npm_project)
assert detected.language == "javascript"
assert detected.module_root == javascript_npm_project / "src"
assert detected.test_runner == "jest"
assert any("prettier" in cmd for cmd in detected.formatter_cmds)
def test_javascript_yarn_detection(self, javascript_yarn_project):
"""Should correctly detect JavaScript yarn project."""
detected = detect_project(javascript_yarn_project)
assert detected.language == "javascript"
assert detected.module_root == javascript_yarn_project / "lib"
assert detected.tests_root == javascript_yarn_project / "__tests__"
def test_javascript_pnpm_detection(self, javascript_pnpm_project):
"""Should correctly detect JavaScript pnpm project."""
detected = detect_project(javascript_pnpm_project)
assert detected.language == "javascript"
assert detected.test_runner == "vitest"
def test_javascript_bun_detection(self, javascript_bun_project):
"""Should correctly detect JavaScript bun project."""
detected = detect_project(javascript_bun_project)
assert detected.language == "javascript"
assert detected.module_root == javascript_bun_project / "src"
def test_typescript_detection(self, typescript_project):
"""Should correctly detect TypeScript project."""
detected = detect_project(typescript_project)
assert detected.language == "typescript"
assert detected.test_runner == "vitest"
assert detected.tests_root == typescript_project / "tests"
def test_typescript_react_detection(self, typescript_react_project):
"""Should correctly detect TypeScript React project."""
detected = detect_project(typescript_react_project)
assert detected.language == "typescript"
assert detected.module_root == typescript_react_project / "src"
# React scripts uses jest under the hood
assert detected.test_runner == "jest"
# =============================================================================
# E2E Tests: First Run Check
# =============================================================================
class TestE2EFirstRunCheck:
"""E2E tests for first-run detection."""
def test_is_first_run_new_python_project(self, python_src_layout):
"""Should detect first run for new Python project."""
assert is_first_run(python_src_layout) is True
def test_is_first_run_new_js_project(self, javascript_npm_project):
"""Should detect first run for new JS project."""
assert is_first_run(javascript_npm_project) is True
def test_is_not_first_run_configured_project(self, project_with_existing_config):
"""Should detect existing config."""
assert is_first_run(project_with_existing_config) is False
def test_has_existing_config_python(self, project_with_existing_config):
"""Should find existing config in pyproject.toml."""
has_config, config_type = has_existing_config(project_with_existing_config)
assert has_config is True
assert config_type == "pyproject.toml"
def test_has_existing_config_js(self, tmp_path):
"""Should find existing config in package.json."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "test",
"codeflash": {"moduleRoot": "src"}
}))
has_config, config_type = has_existing_config(tmp_path)
assert has_config is True
assert config_type == "package.json"
# =============================================================================
# E2E Tests: Config Writing
# =============================================================================
class TestE2EConfigWriting:
"""E2E tests for writing config to native files."""
def test_write_config_python_preserves_existing(self, python_src_layout):
"""Should write config while preserving existing pyproject.toml content."""
detected = detect_project(python_src_layout)
success, message = write_config(detected)
assert success is True
# Read back and verify
content = (python_src_layout / "pyproject.toml").read_text()
data = tomlkit.parse(content)
# Original content preserved
assert data["tool"]["poetry"]["name"] == "myapp"
assert data["tool"]["ruff"]["line-length"] == 120
assert data["tool"]["pytest"]["ini_options"]["testpaths"] == ["tests"]
# Codeflash config added
assert "codeflash" in data["tool"]
assert "module-root" in data["tool"]["codeflash"]
def test_write_config_javascript_preserves_existing(self, javascript_npm_project):
"""Should write config while preserving existing package.json content."""
detected = detect_project(javascript_npm_project)
success, message = write_config(detected)
assert success is True
# Read back and verify
with (javascript_npm_project / "package.json").open() as f:
data = json.load(f)
# Original content preserved
assert data["name"] == "my-js-app"
assert data["devDependencies"]["jest"] == "^29.7.0"
assert data["scripts"]["test"] == "jest"
def test_write_config_typescript(self, typescript_project):
"""Should write config for TypeScript project."""
detected = detect_project(typescript_project)
success, message = write_config(detected)
assert success is True
with (typescript_project / "package.json").open() as f:
data = json.load(f)
# tsconfig.json should be unchanged
tsconfig = json.loads((typescript_project / "tsconfig.json").read_text())
assert tsconfig["compilerOptions"]["strict"] is True
def test_config_roundtrip_python(self, python_flat_layout):
"""Should be able to read back written config."""
# Detect and write
detected = detect_project(python_flat_layout)
write_config(detected)
# Read back
content = (python_flat_layout / "pyproject.toml").read_text()
data = tomlkit.parse(content)
codeflash_section = data["tool"]["codeflash"]
# Create config from written data
config = CodeflashConfig.from_pyproject_dict(dict(codeflash_section))
assert config.language == "python"
assert "myapp" in config.module_root
def test_config_roundtrip_javascript(self, javascript_npm_project):
"""Should be able to read back written config for JS."""
detected = detect_project(javascript_npm_project)
write_config(detected)
with (javascript_npm_project / "package.json").open() as f:
data = json.load(f)
if "codeflash" in data:
config = CodeflashConfig.from_package_json_dict(data["codeflash"])
assert config.language == "javascript"
# =============================================================================
# E2E Tests: First Run Experience
# =============================================================================
class TestE2EFirstRunExperience:
"""E2E tests for the complete first-run experience."""
def test_first_run_python_project(self, python_src_layout, monkeypatch):
"""Should complete first-run for Python project."""
monkeypatch.chdir(python_src_layout)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345")
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert result.language == "python"
assert result.module_root.endswith("myapp")
assert result.tests_root is not None
assert result.tests_root.endswith("tests")
assert result.pytest_cmd == "pytest"
# Config should be written
content = (python_src_layout / "pyproject.toml").read_text()
assert "[tool.codeflash]" in content
def test_first_run_javascript_project(self, javascript_npm_project, monkeypatch):
"""Should complete first-run for JavaScript project."""
monkeypatch.chdir(javascript_npm_project)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345")
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert result.language == "javascript"
assert result.module_root.endswith("src")
assert result.pytest_cmd == "jest" # Maps to test_runner
def test_first_run_typescript_project(self, typescript_project, monkeypatch):
"""Should complete first-run for TypeScript project."""
monkeypatch.chdir(typescript_project)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345")
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert result.language == "typescript"
assert result.pytest_cmd == "vitest"
def test_first_run_with_existing_args(self, python_flat_layout, monkeypatch):
"""Should merge with existing CLI args."""
monkeypatch.chdir(python_flat_layout)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345")
existing_args = Namespace(
file="myapp/core.py",
function="process",
custom_flag=True,
)
result = handle_first_run(
args=existing_args,
skip_confirm=True,
skip_api_key=True,
)
assert result is not None
assert result.custom_flag is True # Preserved
assert result.file == "myapp/core.py" # Preserved
assert result.language == "python" # Added
def test_subsequent_run_not_first_run(self, project_with_existing_config, monkeypatch):
"""Should not trigger first-run for configured project."""
monkeypatch.chdir(project_with_existing_config)
assert is_first_run(project_with_existing_config) is False
# =============================================================================
# E2E Tests: Edge Cases
# =============================================================================
class TestE2EEdgeCases:
"""E2E tests for edge cases and special scenarios."""
def test_empty_directory(self, tmp_path):
"""Should handle empty directory gracefully."""
detected = detect_project(tmp_path)
# Should default to Python
assert detected.language == "python"
# Low language confidence (0.3) + base (0.6) = ~0.72
assert detected.confidence < 0.8
def test_nested_project_detection(self, tmp_path):
"""Should find project root from nested directory."""
# Create project structure
(tmp_path / "pyproject.toml").write_text('[project]\nname = "root"')
deep_dir = tmp_path / "src" / "pkg" / "subpkg"
deep_dir.mkdir(parents=True)
# Detect from nested dir
detected = detect_project(deep_dir)
assert detected.project_root == tmp_path
def test_mixed_project_uses_existing_config(self, mixed_python_js_project):
"""Should respect existing config in mixed projects."""
has_config, config_type = has_existing_config(mixed_python_js_project)
assert has_config is True
assert config_type == "pyproject.toml"
def test_project_without_tests_dir(self, tmp_path):
"""Should handle project without tests directory."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "notests"')
(tmp_path / "src").mkdir()
detected = detect_project(tmp_path)
assert detected.tests_root is None
def test_project_without_formatter(self, tmp_path):
"""Should handle project without detectable formatter."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "no-formatter",
"devDependencies": {"jest": "^29.0.0"}
}))
detected = detect_project(tmp_path)
assert detected.formatter_cmds == []
def test_malformed_pyproject_toml(self, tmp_path):
"""Should handle malformed pyproject.toml."""
(tmp_path / "pyproject.toml").write_text("this is not valid toml {{{}}")
# Should not crash, just detect with lower confidence
detected = detect_project(tmp_path)
assert detected is not None
def test_malformed_package_json(self, tmp_path):
"""Should handle malformed package.json."""
(tmp_path / "package.json").write_text("not valid json")
detected = detect_project(tmp_path)
assert detected is not None
def test_display_dict_format(self, python_src_layout):
"""Should generate proper display dict for UI."""
detected = detect_project(python_src_layout)
display = detected.to_display_dict()
assert "Language" in display
assert "Module root" in display
assert "Tests root" in display
assert "Test runner" in display
assert "Formatter" in display
assert "Ignoring" in display
# Values should be user-friendly
assert display["Language"] == "Python"
assert display["Test runner"] == "pytest"
# =============================================================================
# E2E Tests: Config Schema Conversion
# =============================================================================
class TestE2EConfigConversion:
"""E2E tests for config format conversion."""
def test_python_config_to_toml_and_back(self, python_src_layout):
"""Should convert Python config to TOML and back without loss."""
detected = detect_project(python_src_layout)
original_config = CodeflashConfig.from_detected_project(detected)
# Convert to TOML dict
toml_dict = original_config.to_pyproject_dict()
# Convert back
restored_config = CodeflashConfig.from_pyproject_dict(toml_dict)
assert restored_config.module_root == original_config.module_root
assert restored_config.tests_root == original_config.tests_root
def test_js_config_to_json_and_back(self, javascript_npm_project):
"""Should convert JS config to JSON and back without loss."""
detected = detect_project(javascript_npm_project)
original_config = CodeflashConfig.from_detected_project(detected)
# Convert to JSON dict
json_dict = original_config.to_package_json_dict()
# Convert back
restored_config = CodeflashConfig.from_package_json_dict(json_dict)
# Note: Some defaults may differ, check key fields
if json_dict: # Only if there were non-default values
assert restored_config.language == "javascript"
# =============================================================================
# E2E Tests: Real-world Scenarios
# =============================================================================
class TestE2ERealWorldScenarios:
"""E2E tests simulating real-world usage scenarios."""
def test_scenario_new_user_python(self, python_src_layout, monkeypatch):
"""Scenario: New user runs codeflash on Python project for first time."""
monkeypatch.chdir(python_src_layout)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
# Step 1: Check if first run
assert is_first_run() is True
# Step 2: Handle first run
args = handle_first_run(skip_confirm=True, skip_api_key=True)
assert args is not None
# Step 3: Config is now saved
assert is_first_run() is False
# Step 4: Next run should use saved config
has_config, _ = has_existing_config(python_src_layout)
assert has_config is True
def test_scenario_new_user_javascript(self, javascript_npm_project, monkeypatch):
"""Scenario: New user runs codeflash on JS project for first time."""
monkeypatch.chdir(javascript_npm_project)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
# Step 1: Check if first run
assert is_first_run() is True
# Step 2: Handle first run
args = handle_first_run(skip_confirm=True, skip_api_key=True)
assert args is not None
assert args.language == "javascript"
# Step 3: Verify package.json was updated (or left minimal)
with (javascript_npm_project / "package.json").open() as f:
data = json.load(f)
# Original content should still be there
assert data["name"] == "my-js-app"
def test_scenario_existing_user_reconfigure(self, project_with_existing_config, monkeypatch):
"""Scenario: Existing user wants to reconfigure."""
monkeypatch.chdir(project_with_existing_config)
# Not first run
assert is_first_run() is False
# But user can still detect and see what would be configured
detected = detect_project()
assert detected.language == "python"
def test_scenario_ci_environment(self, python_src_layout, monkeypatch):
"""Scenario: Running in CI environment (non-interactive)."""
monkeypatch.chdir(python_src_layout)
monkeypatch.setenv("CI", "true")
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
# In CI, we need config to exist or use --yes flag
# First run should still work with skip flags
args = handle_first_run(skip_confirm=True, skip_api_key=True)
assert args is not None
# =============================================================================
# E2E Tests: CLI Flags
# =============================================================================
class TestE2ECLIFlags:
"""E2E tests for --show-config and --reset-config CLI flags."""
def test_show_config_displays_detected_settings(self, python_src_layout, monkeypatch, capsys):
"""Should display detected project settings."""
monkeypatch.chdir(python_src_layout)
from codeflash.cli_cmds.cli import _handle_show_config
_handle_show_config()
# Can't easily capture Rich output, but ensure it doesn't crash
# and the function completes successfully
def test_show_config_indicates_saved_vs_detected(self, project_with_existing_config, monkeypatch):
"""Should indicate when config is saved vs auto-detected."""
monkeypatch.chdir(project_with_existing_config)
from codeflash.cli_cmds.cli import _handle_show_config
# Should complete without error
_handle_show_config()
def test_reset_config_removes_from_pyproject(self, project_with_existing_config, monkeypatch):
"""Should remove codeflash config from pyproject.toml."""
monkeypatch.chdir(project_with_existing_config)
from codeflash.cli_cmds.cli import _handle_reset_config
# Verify config exists before
content_before = (project_with_existing_config / "pyproject.toml").read_text()
assert "[tool.codeflash]" in content_before
_handle_reset_config(confirm=False)
# Verify config removed after
content_after = (project_with_existing_config / "pyproject.toml").read_text()
assert "[tool.codeflash]" not in content_after
# Other sections should remain
assert "[project]" in content_after
def test_reset_config_removes_from_package_json(self, javascript_npm_project, monkeypatch):
"""Should remove codeflash config from package.json."""
# First add config
monkeypatch.chdir(javascript_npm_project)
# Add codeflash section
with (javascript_npm_project / "package.json").open() as f:
data = json.load(f)
data["codeflash"] = {"moduleRoot": "src"}
with (javascript_npm_project / "package.json").open("w") as f:
json.dump(data, f, indent=2)
from codeflash.cli_cmds.cli import _handle_reset_config
_handle_reset_config(confirm=False)
# Verify config removed
with (javascript_npm_project / "package.json").open() as f:
data_after = json.load(f)
assert "codeflash" not in data_after
assert data_after["name"] == "my-js-app" # Other content preserved
def test_reset_config_handles_no_config(self, python_src_layout, monkeypatch):
"""Should handle gracefully when no config exists to reset."""
monkeypatch.chdir(python_src_layout)
from codeflash.cli_cmds.cli import _handle_reset_config
# Should not crash when no codeflash config exists
_handle_reset_config(confirm=False)

View file

@ -0,0 +1,287 @@
"""Tests for the first-run experience."""
import json
import os
from argparse import Namespace
from unittest.mock import patch
from codeflash.setup.first_run import (
_handle_api_key,
_prompt_confirmation,
_show_detected_settings,
_show_welcome,
handle_first_run,
is_first_run,
)
class TestIsFirstRun:
"""Tests for is_first_run function."""
def test_returns_true_when_no_config(self, tmp_path):
"""Should return True when no codeflash config exists."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
result = is_first_run(tmp_path)
assert result is True
def test_returns_false_when_pyproject_config_exists(self, tmp_path):
"""Should return False when codeflash config exists in pyproject.toml."""
(tmp_path / "pyproject.toml").write_text(
'[tool.codeflash]\nmodule-root = "src"'
)
result = is_first_run(tmp_path)
assert result is False
def test_returns_false_when_package_json_config_exists(self, tmp_path):
"""Should return False when codeflash config exists in package.json."""
(tmp_path / "package.json").write_text(json.dumps({
"name": "test",
"codeflash": {"moduleRoot": "src"}
}))
result = is_first_run(tmp_path)
assert result is False
def test_returns_true_for_empty_directory(self, tmp_path):
"""Should return True for empty directory."""
result = is_first_run(tmp_path)
assert result is True
class TestHandleFirstRun:
"""Tests for handle_first_run function."""
def test_returns_args_on_success(self, tmp_path, monkeypatch):
"""Should return updated args on successful first run."""
# Create Python project
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "src").mkdir()
(tmp_path / "src" / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
# Mock user input and API key
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
# Skip confirmation
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert hasattr(result, "module_root")
assert hasattr(result, "language")
assert result.language == "python"
def test_returns_none_when_user_cancels(self, tmp_path, monkeypatch):
"""Should return None when user cancels."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
monkeypatch.chdir(tmp_path)
# Mock user cancellation
with patch("codeflash.setup.first_run._prompt_confirmation", return_value="n"):
result = handle_first_run(skip_api_key=True)
assert result is None
def test_skips_confirm_with_flag(self, tmp_path, monkeypatch):
"""Should skip confirmation when skip_confirm=True."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "src").mkdir()
(tmp_path / "src" / "__init__.py").write_text("")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
# Should not prompt for confirmation
with patch("codeflash.setup.first_run._prompt_confirmation") as mock_prompt:
result = handle_first_run(skip_confirm=True, skip_api_key=True)
mock_prompt.assert_not_called()
assert result is not None
def test_merges_with_existing_args(self, tmp_path, monkeypatch):
"""Should merge detected settings with existing args."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
(tmp_path / "src").mkdir()
(tmp_path / "src" / "__init__.py").write_text("")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
existing_args = Namespace(custom_flag=True, module_root=None)
result = handle_first_run(
args=existing_args,
skip_confirm=True,
skip_api_key=True,
)
assert result is not None
assert result.custom_flag is True # Preserved
assert result.module_root is not None # Updated
class TestPromptConfirmation:
"""Tests for _prompt_confirmation function."""
def test_returns_y_for_yes(self, monkeypatch):
"""Should return 'y' for yes input."""
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
with patch("codeflash.cli_cmds.console.console.input", return_value="y"):
result = _prompt_confirmation()
assert result == "y"
def test_returns_y_for_empty(self, monkeypatch):
"""Should return 'y' for empty input (default)."""
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
with patch("codeflash.cli_cmds.console.console.input", return_value=""):
result = _prompt_confirmation()
assert result == "y"
def test_returns_n_for_no(self, monkeypatch):
"""Should return 'n' for no input."""
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
with patch("codeflash.cli_cmds.console.console.input", return_value="n"):
result = _prompt_confirmation()
assert result == "n"
def test_returns_customize_for_c(self, monkeypatch):
"""Should return 'customize' for c input."""
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
with patch("codeflash.cli_cmds.console.console.input", return_value="c"):
result = _prompt_confirmation()
assert result == "customize"
def test_returns_n_for_non_interactive(self, monkeypatch):
"""Should return 'n' for non-interactive environment."""
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
result = _prompt_confirmation()
assert result == "n"
class TestHandleApiKey:
"""Tests for _handle_api_key function."""
def test_returns_true_when_key_exists(self, monkeypatch):
"""Should return True when API key already exists."""
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-existing-key")
with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", return_value="cf-existing-key"):
result = _handle_api_key()
assert result is True
def test_accepts_valid_key(self, monkeypatch):
"""Should accept valid API key starting with cf-."""
monkeypatch.delenv("CODEFLASH_API_KEY", raising=False)
with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", side_effect=OSError):
with patch("codeflash.cli_cmds.console.console.input", return_value="cf-valid-key"):
# Patch the entire import to fail, triggering the exception handler
with patch.dict("sys.modules", {"codeflash.code_utils.shell_utils": None}):
result = _handle_api_key()
assert result is True
assert os.environ.get("CODEFLASH_API_KEY") == "cf-valid-key"
def test_rejects_invalid_key(self, monkeypatch):
"""Should reject API key not starting with cf-."""
monkeypatch.delenv("CODEFLASH_API_KEY", raising=False)
with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", side_effect=OSError):
with patch("codeflash.cli_cmds.console.console.input", return_value="invalid-key"):
result = _handle_api_key()
assert result is False
class TestShowFunctions:
"""Tests for display functions (smoke tests)."""
def test_show_welcome_does_not_crash(self):
"""Should not crash when showing welcome message."""
# Just verify it doesn't raise an exception
_show_welcome()
def test_show_detected_settings_does_not_crash(self, tmp_path):
"""Should not crash when showing detected settings."""
from codeflash.setup.detector import detect_project
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"')
detected = detect_project(tmp_path)
# Just verify it doesn't raise an exception
_show_detected_settings(detected)
class TestFirstRunIntegration:
"""Integration tests for the complete first-run flow."""
def test_full_python_first_run(self, tmp_path, monkeypatch):
"""Should complete full first-run for Python project."""
# Create Python project
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "myapp"\n\n[tool.ruff]\nline-length = 120'
)
pkg_dir = tmp_path / "myapp"
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text("")
(tmp_path / "tests").mkdir()
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert result.language == "python"
assert result.module_root.endswith("myapp")
assert result.tests_root.endswith("tests")
# Verify config was written
import tomlkit
content = (tmp_path / "pyproject.toml").read_text()
data = tomlkit.parse(content)
assert "codeflash" in data["tool"]
def test_full_javascript_first_run(self, tmp_path, monkeypatch):
"""Should complete full first-run for JavaScript project."""
# Create JS project
(tmp_path / "package.json").write_text(json.dumps({
"name": "myapp",
"devDependencies": {"jest": "^29.0.0"}
}, indent=2))
(tmp_path / "src").mkdir()
(tmp_path / "tests").mkdir()
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key")
result = handle_first_run(skip_confirm=True, skip_api_key=True)
assert result is not None
assert result.language == "javascript"
assert result.module_root.endswith("src")
assert result.pytest_cmd == "jest" # test_runner mapped to pytest_cmd
def test_subsequent_run_uses_saved_config(self, tmp_path, monkeypatch):
"""After first run, subsequent runs should not trigger first-run."""
# Create project with existing config
(tmp_path / "pyproject.toml").write_text(
'[tool.codeflash]\nmodule-root = "src"'
)
monkeypatch.chdir(tmp_path)
# Should not be first run
assert is_first_run(tmp_path) is False