mirror of
https://github.com/codeflash-ai/codeflash.git
synced 2026-05-04 18:25:17 +00:00
Merge branch 'main' into add_vitest_support_to_js
This commit is contained in:
commit
aa9b926200
16 changed files with 3671 additions and 29 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
22
codeflash/setup/__init__.py
Normal file
22
codeflash/setup/__init__.py
Normal 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",
|
||||
]
|
||||
197
codeflash/setup/config_schema.py
Normal file
197
codeflash/setup/config_schema.py
Normal 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)
|
||||
246
codeflash/setup/config_writer.py
Normal file
246
codeflash/setup/config_writer.py
Normal 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
692
codeflash/setup/detector.py
Normal 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
|
||||
294
codeflash/setup/first_run.py
Normal file
294
codeflash/setup/first_run.py
Normal 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")
|
||||
4
packages/codeflash/package-lock.json
generated
4
packages/codeflash/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
tests/test_setup/__init__.py
Normal file
1
tests/test_setup/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Tests for the codeflash.setup module
|
||||
395
tests/test_setup/test_config.py
Normal file
395
tests/test_setup/test_config.py
Normal 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'}"
|
||||
381
tests/test_setup/test_detector.py
Normal file
381
tests/test_setup/test_detector.py
Normal 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
|
||||
909
tests/test_setup/test_e2e_setup.py
Normal file
909
tests/test_setup/test_e2e_setup.py
Normal 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)
|
||||
287
tests/test_setup/test_first_run.py
Normal file
287
tests/test_setup/test_first_run.py
Normal 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
|
||||
Loading…
Reference in a new issue