diff --git a/.claude/hooks/post-edit-lint.sh b/.claude/hooks/post-edit-lint.sh index 94332e3e7..714b7fafb 100755 --- a/.claude/hooks/post-edit-lint.sh +++ b/.claude/hooks/post-edit-lint.sh @@ -10,5 +10,6 @@ if [[ -z "$file_path" || ! -f "$file_path" ]]; then fi if [[ "$file_path" == *.py ]]; then - uv run prek --files "$file_path" 2>/dev/null || true + # First run auto-fixes formatting; second run catches real lint errors + uv run prek --files "$file_path" 2>/dev/null || uv run prek --files "$file_path" fi diff --git a/codeflash/benchmarking/compare.py b/codeflash/benchmarking/compare.py new file mode 100644 index 000000000..fb98ef301 --- /dev/null +++ b/codeflash/benchmarking/compare.py @@ -0,0 +1,609 @@ +"""Cross-branch benchmark comparison. + +Compares benchmark performance between two git refs by: +1. Auto-detecting changed functions (or using an explicit list) +2. Creating worktrees for each ref +3. Instrumenting functions with @codeflash_trace +4. Running benchmarks via trace_benchmarks_pytest +5. Rendering a side-by-side Rich comparison table +""" + +from __future__ import annotations + +import ast +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +import git +from rich.table import Table + +from codeflash.cli_cmds.console import console, logger + +if TYPE_CHECKING: + from collections.abc import Callable + + from codeflash.models.function_types import FunctionToOptimize + from codeflash.models.models import BenchmarkKey + + +@dataclass +class CompareResult: + base_ref: str + head_ref: str + base_total_ns: dict[BenchmarkKey, int] = field(default_factory=dict) + head_total_ns: dict[BenchmarkKey, int] = field(default_factory=dict) + base_function_ns: dict[str, dict[BenchmarkKey, int]] = field(default_factory=dict) + head_function_ns: dict[str, dict[BenchmarkKey, int]] = field(default_factory=dict) + + def format_markdown(self) -> str: + """Format comparison results as GitHub-flavored markdown (for programmatic use, e.g. PR comments).""" + if not self.base_total_ns and not self.head_total_ns: + return "_No benchmark results to compare._" + + base_short = self.base_ref[:12] + head_short = self.head_ref[:12] + all_keys = sorted(set(self.base_total_ns) | set(self.head_total_ns), key=str) + sections: list[str] = [f"## Benchmark: `{base_short}` vs `{head_short}`"] + + for bm_key in all_keys: + base_ns = self.base_total_ns.get(bm_key) + head_ns = self.head_total_ns.get(bm_key) + + # Extract short benchmark name from the full key + bm_name = str(bm_key).rsplit("::", 1)[-1] if "::" in str(bm_key) else str(bm_key) + + # --- End-to-End table --- + lines = [ + f"### {bm_name}", + "", + "| Branch | Time (ms) | vs base | Speedup |", + "|:---|---:|---:|---:|", + f"| `{base_short}` (base) | {_fmt_ms(base_ns)} | - | - |", + f"| `{head_short}` (head) | {_fmt_ms(head_ns)} " + f"| {_md_delta(base_ns, head_ns)} | {_md_speedup(base_ns, head_ns)} |", + ] + + # --- Per-function breakdown --- + all_funcs: set[str] = set() + for d in [self.base_function_ns, self.head_function_ns]: + for func_name, bm_dict in d.items(): + if bm_key in bm_dict: + all_funcs.add(func_name) + + if all_funcs: + + def sort_key(fn: str, _bm_key: BenchmarkKey = bm_key) -> int: + return self.base_function_ns.get(fn, {}).get(_bm_key, 0) + + sorted_funcs = sorted(all_funcs, key=sort_key, reverse=True) + + lines.append("") + lines.append("| Function | base (ms) | head (ms) | Improvement | Speedup |") + lines.append("|:---|---:|---:|:---|---:|") + + for func_name in sorted_funcs: + b = self.base_function_ns.get(func_name, {}).get(bm_key) + h = self.head_function_ns.get(func_name, {}).get(bm_key) + short_name = func_name.rsplit(".", 1)[-1] if "." in func_name else func_name + lines.append( + f"| `{short_name}` | {_fmt_ms(b)} | {_fmt_ms(h)} | {_md_bar(b, h)} | {_md_speedup(b, h)} |" + ) + + lines.append( + f"| **TOTAL** | **{_fmt_ms(base_ns)}** | **{_fmt_ms(head_ns)}** " + f"| {_md_bar(base_ns, head_ns)} | {_md_speedup(base_ns, head_ns)} |" + ) + + # --- Share of Benchmark Time (%) --- + if base_ns and head_ns: + lines.append("") + lines.append("
Share of Benchmark Time") + lines.append("") + lines.append("| Function | base | head |") + lines.append("|:---|:---|:---|") + + for func_name in sorted_funcs: + b = self.base_function_ns.get(func_name, {}).get(bm_key) + h = self.head_function_ns.get(func_name, {}).get(bm_key) + short_name = func_name.rsplit(".", 1)[-1] if "." in func_name else func_name + b_pct = b / base_ns * 100 if b else 0 + h_pct = h / head_ns * 100 if h else 0 + lines.append(f"| `{short_name}` | {_pct_bar(b_pct)} | {_pct_bar(h_pct)} |") + + lines.append("") + lines.append("
") + + sections.append("\n".join(lines)) + + sections.append("---\n*Generated by codeflash optimization agent*") + return "\n\n".join(sections) + + +def compare_branches( + base_ref: str, + head_ref: str, + project_root: Path, + benchmarks_root: Path, + tests_root: Path, + functions: Optional[dict[Path, list[FunctionToOptimize]]] = None, + timeout: int = 600, +) -> CompareResult: + """Compare benchmark performance between two git refs. + + If functions is None, auto-detects changed functions from git diff. + Returns a CompareResult with timing data from both refs. + """ + from codeflash.benchmarking.instrument_codeflash_trace import instrument_codeflash_trace_decorator + from codeflash.benchmarking.plugin.plugin import CodeFlashBenchmarkPlugin + from codeflash.benchmarking.trace_benchmarks import trace_benchmarks_pytest + + repo = git.Repo(project_root, search_parent_directories=True) + repo_root = Path(repo.working_dir) + + # Auto-detect functions if not provided + if functions is None: + functions = _discover_changed_functions(base_ref, head_ref, repo_root) + if not functions: + logger.warning("No changed Python functions found between %s and %s", base_ref, head_ref) + return CompareResult(base_ref=base_ref, head_ref=head_ref) + + from rich.live import Live + from rich.panel import Panel + from rich.text import Text + + base_short = base_ref[:12] + head_short = head_ref[:12] + + func_count = sum(len(fns) for fns in functions.values()) + file_count = len(functions) + + # Build function tree for the panel + from os.path import commonpath + + from rich.tree import Tree + + rel_paths = [] + for fp in functions: + rel_paths.append(fp.relative_to(repo_root) if fp.is_relative_to(repo_root) else fp) + + # Strip common prefix so paths are short but unambiguous + if len(rel_paths) > 1: + common = Path(commonpath(rel_paths)) + short_paths = [p.relative_to(common) if p != common else Path(p.name) for p in rel_paths] + else: + short_paths = [Path(p.name) for p in rel_paths] + + fn_tree = Tree(f"[bold]{func_count} functions[/bold] [dim]across {file_count} files[/dim]", guide_style="dim") + for (_fp, fns), short in zip(functions.items(), short_paths): + branch = fn_tree.add(f"[cyan]{short}[/cyan]") + for fn in fns: + branch.add(f"[bold]{fn.function_name}[/bold]") + + # Set up worktree paths and trace DB paths + from codeflash.code_utils.git_worktree_utils import worktree_dirs + + worktree_dirs.mkdir(parents=True, exist_ok=True) + timestamp = time.strftime("%Y%m%d-%H%M%S") + + base_worktree = worktree_dirs / f"compare-base-{timestamp}" + head_worktree = worktree_dirs / f"compare-head-{timestamp}" + base_trace_db = worktree_dirs / f"trace-base-{timestamp}.db" + head_trace_db = worktree_dirs / f"trace-head-{timestamp}.db" + + result = CompareResult(base_ref=base_ref, head_ref=head_ref) + + from rich.console import Group + + step_labels = ["Creating worktrees", f"Benchmarking base ({base_short})", f"Benchmarking head ({head_short})"] + + def build_steps(current_step: int) -> Group: + lines: list[Text] = [] + for i, label in enumerate(step_labels): + if i < current_step: + lines.append(Text.from_markup(f"[green]\u2714[/green] {label}")) + elif i == current_step: + lines.append(Text.from_markup(f"[cyan]\u25cb[/cyan] {label}...")) + else: + lines.append(Text.from_markup(f"[dim]\u2500 {label}[/dim]")) + return Group(*lines) + + def build_panel(current_step: int) -> Panel: + # Two-column grid: tree left, steps right (vertically padded to center) + tree_height = 1 + sum(1 + len(fns) for fns in functions.values()) # root + files + functions + step_count = len(step_labels) + pad_top = max(0, (tree_height - step_count) // 2) + + grid = Table(box=None, show_header=False, expand=True, padding=0) + grid.add_column(ratio=3) + grid.add_column(ratio=2) + grid.add_row(fn_tree, Group(*([Text("")] * pad_top), build_steps(current_step))) + + return Panel( + Group( + Text.from_markup( + f"[bold cyan]{base_short}[/bold cyan] (base) vs [bold cyan]{head_short}[/bold cyan] (head)" + ), + "", + grid, + ), + title="[bold]Benchmark Compare[/bold]", + border_style="cyan", + expand=True, + padding=(1, 2), + ) + + try: + with Live(build_panel(0), console=console, refresh_per_second=1) as live: + # Step 1: Create worktrees (resolve to SHAs to avoid "already checked out" errors) + base_sha = repo.commit(base_ref).hexsha + head_sha = repo.commit(head_ref).hexsha + repo.git.worktree("add", str(base_worktree), base_sha) + repo.git.worktree("add", str(head_worktree), head_sha) + live.update(build_panel(1)) + + # Step 2: Run benchmarks on base + _run_benchmark_on_worktree( + worktree_dir=base_worktree, + repo_root=repo_root, + functions=functions, + benchmarks_root=benchmarks_root, + tests_root=tests_root, + trace_db=base_trace_db, + timeout=timeout, + instrument_fn=instrument_codeflash_trace_decorator, + trace_fn=trace_benchmarks_pytest, + ) + live.update(build_panel(2)) + + # Step 3: Run benchmarks on head + _run_benchmark_on_worktree( + worktree_dir=head_worktree, + repo_root=repo_root, + functions=functions, + benchmarks_root=benchmarks_root, + tests_root=tests_root, + trace_db=head_trace_db, + timeout=timeout, + instrument_fn=instrument_codeflash_trace_decorator, + trace_fn=trace_benchmarks_pytest, + ) + + # Load results + if base_trace_db.exists(): + result.base_total_ns = CodeFlashBenchmarkPlugin.get_benchmark_timings(base_trace_db) + result.base_function_ns = CodeFlashBenchmarkPlugin.get_function_benchmark_timings(base_trace_db) + + if head_trace_db.exists(): + result.head_total_ns = CodeFlashBenchmarkPlugin.get_benchmark_timings(head_trace_db) + result.head_function_ns = CodeFlashBenchmarkPlugin.get_function_benchmark_timings(head_trace_db) + + # Render comparison + _render_comparison(result) + + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted — cleaning up...[/yellow]") + + finally: + # Cleanup worktrees + from codeflash.code_utils.git_worktree_utils import remove_worktree + + remove_worktree(base_worktree) + remove_worktree(head_worktree) + repo.git.worktree("prune") + # Cleanup trace DBs + for db in [base_trace_db, head_trace_db]: + if db.exists(): + db.unlink() + + return result + + +def _discover_changed_functions(base_ref: str, head_ref: str, repo_root: Path) -> dict[Path, list[FunctionToOptimize]]: + """Find only functions whose bodies overlap with changed lines between refs.""" + from io import StringIO + + from unidiff import PatchSet + + repo = git.Repo(repo_root, search_parent_directories=True) + + # Get the diff with line-level detail + try: + uni_diff_text = repo.git.diff(f"{base_ref}...{head_ref}", ignore_blank_lines=True, ignore_space_at_eol=True) + except git.GitCommandError: + uni_diff_text = repo.git.diff(base_ref, head_ref, ignore_blank_lines=True, ignore_space_at_eol=True) + + if not uni_diff_text.strip(): + return {} + + patch_set = PatchSet(StringIO(uni_diff_text)) + + # Build map: file_path -> set of changed line numbers (in the target/head version) + changed_lines_by_file: dict[Path, set[int]] = {} + for patched_file in patch_set: + file_path = Path(patched_file.path) + if file_path.suffix != ".py": + continue + abs_path = repo_root / file_path + + added_lines: set[int] = { + line.target_line_no + for hunk in patched_file + for line in hunk + if line.is_added and line.value.strip() and line.target_line_no is not None + } + deleted_lines: set[int] = {hunk.target_start for hunk in patched_file} + # Use added lines if available, otherwise use hunk starts (deletion-only changes) + line_nos: set[int] = added_lines if added_lines else deleted_lines + if line_nos: + changed_lines_by_file[abs_path] = line_nos + + # Discover top-level functions in changed files using ast (lightweight, no libcst overhead) + result: dict[Path, list[FunctionToOptimize]] = {} + for abs_path, changed_lines in changed_lines_by_file.items(): + if not abs_path.exists(): + logger.debug(f"Skipping {abs_path} (does not exist)") + continue + + modified_fns = _find_changed_toplevel_functions(abs_path, changed_lines) + if modified_fns: + result[abs_path] = modified_fns + + return result + + +def _find_changed_toplevel_functions(file_path: Path, changed_lines: set[int]) -> list[FunctionToOptimize]: + """Find top-level functions overlapping changed lines using stdlib ast. + + Only discovers module-level functions (not methods inside classes, not nested + functions). This is intentional: class methods can be called thousands of times + in benchmarks (e.g. CST visitor methods), and @codeflash_trace pickles self on + every call -- catastrophic overhead when self holds a full CST tree. + """ + from codeflash.models.function_types import FunctionToOptimize + + try: + source = file_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError): + logger.debug(f"Skipping {file_path} (parse error)") + return [] + + functions: list[FunctionToOptimize] = [] + for node in ast.iter_child_nodes(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.end_lineno is None: + continue + fn_lines = range(node.lineno, node.end_lineno + 1) + if not changed_lines.isdisjoint(fn_lines): + functions.append( + FunctionToOptimize( + function_name=node.name, + file_path=file_path, + parents=[], + starting_line=node.lineno, + ending_line=node.end_lineno, + is_async=isinstance(node, ast.AsyncFunctionDef), + is_method=False, + ) + ) + + return functions + + +def _run_benchmark_on_worktree( + worktree_dir: Path, + repo_root: Path, + functions: dict[Path, list[FunctionToOptimize]], + benchmarks_root: Path, + tests_root: Path, + trace_db: Path, + timeout: int, + instrument_fn: Callable[[dict[Path, list[FunctionToOptimize]]], None], + trace_fn: Callable[[Path, Path, Path, Path, int], None], +) -> None: + """Instrument, benchmark, and restore source in a worktree.""" + from codeflash.models.function_types import FunctionToOptimize + + # Remap function paths from repo_root to worktree_dir + worktree_functions: dict[Path, list[FunctionToOptimize]] = {} + for file_path, fns in functions.items(): + rel = file_path.relative_to(repo_root) if file_path.is_relative_to(repo_root) else file_path + wt_path = worktree_dir / rel + if not wt_path.exists(): + logger.debug(f"Skipping {rel} (not present in this ref)") + continue + + remapped_fns = [] + for fn in fns: + remapped_fns.append( + FunctionToOptimize( + function_name=fn.function_name, + file_path=wt_path, + parents=fn.parents, + is_method=fn.is_method, + is_async=fn.is_async, + ) + ) + worktree_functions[wt_path] = remapped_fns + + if not worktree_functions: + logger.warning("No instrumentable functions found in this worktree") + return + + # Save original source + original_sources: dict[Path, str] = {} + for file_path in worktree_functions: + original_sources[file_path] = file_path.read_text(encoding="utf-8") + + # Remap benchmark and test roots to worktree + wt_benchmarks = worktree_dir / benchmarks_root.relative_to(repo_root) + wt_tests = worktree_dir / tests_root.relative_to(repo_root) + + if trace_db.exists(): + trace_db.unlink() + + try: + instrument_fn(worktree_functions) + try: + trace_fn(wt_benchmarks, wt_tests, worktree_dir, trace_db, timeout) + except subprocess.TimeoutExpired: + logger.warning(f"Benchmark timed out after {timeout}s — partial results may be available") + finally: + # Restore original source + for file_path, source in original_sources.items(): + file_path.write_text(source, encoding="utf-8") + + +def _render_comparison(result: CompareResult) -> None: + """Render Rich comparison tables to console.""" + if not result.base_total_ns and not result.head_total_ns: + logger.warning("No benchmark results to compare") + return + + base_short = result.base_ref[:12] + head_short = result.head_ref[:12] + + # Find all benchmark keys across both refs + all_benchmark_keys = set(result.base_total_ns.keys()) | set(result.head_total_ns.keys()) + + for bm_key in sorted(all_benchmark_keys, key=str): + # Show only the test function name, not the full module path + bm_name = str(bm_key).rsplit("::", 1)[-1] if "::" in str(bm_key) else str(bm_key) + console.print() + console.rule(f"[bold]{bm_name}[/bold]") + console.print() + + base_ns = result.base_total_ns.get(bm_key) + head_ns = result.head_total_ns.get(bm_key) + + # Table 1: Total benchmark time + t1 = Table(title="End-to-End", border_style="blue", show_lines=True, expand=False) + t1.add_column("Ref", style="bold cyan") + t1.add_column("Time (ms)", justify="right") + t1.add_column("Delta", justify="right") + t1.add_column("Speedup", justify="right") + + t1.add_row(f"{base_short} (base)", _fmt_ms(base_ns), "-", "-") + t1.add_row( + f"{head_short} (head)", _fmt_ms(head_ns), _fmt_delta(base_ns, head_ns), _fmt_speedup(base_ns, head_ns) + ) + console.print(t1, justify="center") + + # Table 2: Per-function breakdown + all_funcs = set() + for d in [result.base_function_ns, result.head_function_ns]: + for func_name, bm_dict in d.items(): + if bm_key in bm_dict: + all_funcs.add(func_name) + + if all_funcs: + console.print() + + t2 = Table(title="Per-Function Breakdown", border_style="blue", show_lines=True, expand=False) + t2.add_column("Function", style="cyan") + t2.add_column("base (ms)", justify="right", style="yellow") + t2.add_column("head (ms)", justify="right", style="yellow") + t2.add_column("Delta", justify="right") + t2.add_column("Speedup", justify="right") + + def sort_key(fn: str, _bm_key: BenchmarkKey = bm_key) -> int: + return result.base_function_ns.get(fn, {}).get(_bm_key, 0) + + for func_name in sorted(all_funcs, key=sort_key, reverse=True): + b_ns = result.base_function_ns.get(func_name, {}).get(bm_key) + h_ns = result.head_function_ns.get(func_name, {}).get(bm_key) + + # Shorten function name for display + short_name = func_name.rsplit(".", 1)[-1] if "." in func_name else func_name + + t2.add_row(short_name, _fmt_ms(b_ns), _fmt_ms(h_ns), _fmt_delta(b_ns, h_ns), _fmt_speedup(b_ns, h_ns)) + + # Totals row + t2.add_section() + t2.add_row( + "[bold]TOTAL[/bold]", + f"[bold]{_fmt_ms(base_ns)}[/bold]", + f"[bold]{_fmt_ms(head_ns)}[/bold]", + _fmt_delta(base_ns, head_ns), + _fmt_speedup(base_ns, head_ns), + ) + console.print(t2, justify="center") + + console.print() + + +def _fmt_ms(ns: Optional[int]) -> str: + if ns is None: + return "-" + ms = ns / 1_000_000 + if ms >= 1000: + return f"{ms:,.0f}" + if ms >= 100: + return f"{ms:.0f}" + if ms >= 1: + return f"{ms:.1f}" + return f"{ms:.2f}" + + +def _fmt_speedup(before: Optional[int], after: Optional[int]) -> str: + if before is None or after is None or after == 0: + return "-" + ratio = before / after + if ratio >= 1: + return f"[green]{ratio:.2f}x[/green]" + return f"[red]{ratio:.2f}x[/red]" + + +def _fmt_delta(before: Optional[int], after: Optional[int]) -> str: + if before is None or after is None: + return "-" + delta_ms = (after - before) / 1_000_000 + pct = ((after - before) / before) * 100 if before != 0 else 0 + if delta_ms < 0: + return f"[green]{delta_ms:+,.0f}ms ({pct:+.0f}%)[/green]" + return f"[red]{delta_ms:+,.0f}ms ({pct:+.0f}%)[/red]" + + +def _md_speedup(before: Optional[int], after: Optional[int]) -> str: + if before is None or after is None or after == 0: + return "-" + ratio = before / after + emoji = "\U0001f7e2" if ratio >= 1 else "\U0001f534" + return f"{emoji} {ratio:.2f}x" + + +def _md_delta(before: Optional[int], after: Optional[int]) -> str: + if before is None or after is None: + return "-" + delta_ms = (after - before) / 1_000_000 + pct = ((after - before) / before) * 100 if before != 0 else 0 + if delta_ms < 0: + return f"{delta_ms:+,.0f}ms ({pct:+.0f}%)" + return f"+{delta_ms:,.0f}ms ({pct:+.0f}%)" + + +def _md_bar(before: Optional[int], after: Optional[int], width: int = 10) -> str: + """Render a unicode progress bar showing the change from before to after. + + Improvement (after < before) shows green filled portion for the reduction. + Regression (after > before) shows the bar in reverse. + """ + if before is None or after is None or before == 0: + return "-" + pct = ((before - after) / before) * 100 + filled = round(abs(pct) / 100 * width) + filled = min(filled, width) + bar = "\u2588" * filled + "\u2591" * (width - filled) + return f"`{bar}` {pct:+.0f}%" + + +def _pct_bar(pct: float, width: int = 10) -> str: + """Render a unicode bar representing a percentage share.""" + filled = round(pct / 100 * width) + filled = max(0, min(filled, width)) + bar = "\u2588" * filled + "\u2591" * (width - filled) + return f"`{bar}` {pct:.1f}%" diff --git a/codeflash/cli_cmds/cli.py b/codeflash/cli_cmds/cli.py index 73b15b0ad..8c2ae783b 100644 --- a/codeflash/cli_cmds/cli.py +++ b/codeflash/cli_cmds/cli.py @@ -24,7 +24,7 @@ def parse_args() -> Namespace: args.no_pr = True args.worktree = True args.effort = "low" - if args.command == "auth": + if args.command in ("auth", "compare"): return args return process_and_validate_cmd_args(args) @@ -381,6 +381,16 @@ def _build_parser() -> ArgumentParser: auth_subparsers.add_parser("login", help="Log in to Codeflash via OAuth") auth_subparsers.add_parser("status", help="Check authentication status") + compare_parser = subparsers.add_parser("compare", help="Compare benchmark performance between two git refs.") + compare_parser.add_argument("base_ref", help="Base git ref (branch, tag, or commit)") + compare_parser.add_argument("head_ref", nargs="?", default=None, help="Head git ref (default: current branch)") + compare_parser.add_argument("--pr", type=int, help="Resolve head ref from a PR number (requires gh CLI)") + compare_parser.add_argument( + "--functions", type=str, help="Explicit functions to instrument: 'file.py::func1,func2;other.py::func3'" + ) + compare_parser.add_argument("--timeout", type=int, default=600, help="Benchmark timeout in seconds (default: 600)") + compare_parser.add_argument("--config-file", type=str, dest="config_file", help="Path to pyproject.toml") + trace_optimize = subparsers.add_parser("optimize", help="Trace and optimize your project.") trace_optimize.add_argument( diff --git a/codeflash/cli_cmds/cmd_compare.py b/codeflash/cli_cmds/cmd_compare.py new file mode 100644 index 000000000..2a20a4c4f --- /dev/null +++ b/codeflash/cli_cmds/cmd_compare.py @@ -0,0 +1,113 @@ +"""CLI handler for `codeflash compare`.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argparse import Namespace + + from codeflash.models.function_types import FunctionToOptimize + +from codeflash.cli_cmds.console import logger +from codeflash.code_utils.config_parser import parse_config_file + + +def run_compare(args: Namespace) -> None: + """Entry point for the compare subcommand.""" + # Load project config + pyproject_config, pyproject_file_path = parse_config_file(args.config_file) + + module_root = Path(pyproject_config.get("module_root", ".")).resolve() + tests_root = Path(pyproject_config.get("tests_root", "tests")).resolve() + benchmarks_root_str = pyproject_config.get("benchmarks_root") + + if not benchmarks_root_str: + logger.error("benchmarks-root must be configured in [tool.codeflash] to use compare") + sys.exit(1) + + benchmarks_root = Path(benchmarks_root_str).resolve() + if not benchmarks_root.is_dir(): + logger.error(f"benchmarks-root {benchmarks_root} is not a valid directory") + sys.exit(1) + + from codeflash.cli_cmds.cli import project_root_from_module_root + + project_root = project_root_from_module_root(module_root, pyproject_file_path) + + # Resolve head_ref + head_ref = args.head_ref + if args.pr: + head_ref = _resolve_pr_branch(args.pr) + if not head_ref: + logger.error("Must provide head_ref or --pr") + sys.exit(1) + + # Parse explicit functions if provided + functions = None + if args.functions: + functions = _parse_functions_arg(args.functions, project_root) + + from codeflash.benchmarking.compare import compare_branches + + result = compare_branches( + base_ref=args.base_ref, + head_ref=head_ref, + project_root=project_root, + benchmarks_root=benchmarks_root, + tests_root=tests_root, + functions=functions, + timeout=args.timeout, + ) + + if not result.base_total_ns and not result.head_total_ns: + logger.warning("No benchmark data collected. Check that benchmarks-root is configured and benchmarks exist.") + sys.exit(1) + + +def _resolve_pr_branch(pr_number: int) -> str: + """Resolve a PR number to its head branch name using gh CLI.""" + try: + result = subprocess.run( + ["gh", "pr", "view", str(pr_number), "--json", "headRefName", "-q", ".headRefName"], + capture_output=True, + text=True, + check=True, + ) + branch = result.stdout.strip() + if branch: + logger.info(f"Resolved PR #{pr_number} to branch: {branch}") + return branch + logger.error(f"Could not resolve PR #{pr_number} to a branch") + sys.exit(1) + except FileNotFoundError: + logger.error("gh CLI not found. Install it from https://cli.github.com/ or provide a branch name directly.") + sys.exit(1) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to resolve PR #{pr_number}: {e.stderr}") + sys.exit(1) + + +def _parse_functions_arg(functions_str: str, project_root: Path) -> dict[Path, list[FunctionToOptimize]]: + """Parse --functions arg format: 'file.py::func1,func2;other.py::func3'.""" + from codeflash.models.function_types import FunctionToOptimize + + result: dict[Path, list[FunctionToOptimize]] = {} + for entry in functions_str.split(";"): + entry = entry.strip() + if "::" not in entry: + logger.warning(f"Skipping malformed functions entry (missing '::'): {entry}") + continue + file_part, funcs_part = entry.split("::", 1) + file_path = (project_root / file_part.strip()).resolve() + if not file_path.exists(): + logger.warning(f"Skipping {file_path} (does not exist)") + continue + func_names = [f.strip() for f in funcs_part.split(",") if f.strip()] + result[file_path] = [ + FunctionToOptimize(function_name=name, file_path=file_path, parents=[]) for name in func_names + ] + return result diff --git a/codeflash/main.py b/codeflash/main.py index 93d53a1f3..da0d83db6 100644 --- a/codeflash/main.py +++ b/codeflash/main.py @@ -71,6 +71,10 @@ def main() -> None: from codeflash.cli_cmds.extension import install_vscode_extension install_vscode_extension() + elif args.command == "compare": + from codeflash.cli_cmds.cmd_compare import run_compare + + run_compare(args) elif args.command == "optimize": from codeflash.tracer import main as tracer_main diff --git a/pyproject.toml b/pyproject.toml index bcfb665a6..f825d3739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,374 +1,374 @@ -[project] -name = "codeflash" -dynamic = ["version"] -description = "Client for codeflash.ai - automatic code performance optimization, powered by AI" -authors = [{ name = "CodeFlash Inc.", email = "contact@codeflash.ai" }] -requires-python = ">=3.9" -readme = "README.md" -license-files = ["LICENSE"] -keywords = [ - "codeflash", - "performance", - "optimization", - "ai", - "code", - "machine learning", - "LLM", -] -dependencies = [ - "unidiff>=0.7.4", - "pytest>=7.0.0", - "gitpython>=3.1.31", - "libcst>=1.0.1", - "jedi>=0.19.1", - # Tree-sitter for multi-language support - "tree-sitter>=0.23.0", - "tree-sitter-javascript>=0.23.0", - "tree-sitter-typescript>=0.23.0", - "tree-sitter-java>=0.23.0", - "tree-sitter-groovy>=0.1.0", - "tree-sitter-kotlin>=1.0.0", - "pytest-timeout>=2.1.0", - "tomlkit>=0.11.7", - "attrs>=23.1.0", - "requests>=2.28.0", - "junitparser>=3.1.0", - "pydantic>=1.10.1", - "humanize>=4.0.0", - "posthog>=3.0.0", - "click>=8.1.0", - "inquirer>=3.0.0", - "sentry-sdk>=1.40.6,<3.0.0", - "parameterized>=0.9.0", - "isort>=5.11.0", - "dill>=0.3.8", - "rich>=13.8.1", - "lxml>=5.3.0", - "crosshair-tool>=0.0.78; python_version < '3.15'", - "coverage>=7.6.4", - "line_profiler>=4.2.0", - "platformdirs>=4.3.7", - "pygls>=2.0.0,<3.0.0", - "codeflash-benchmark", - "filelock>=3.20.3; python_version >= '3.10'", - "filelock<3.20.3; python_version < '3.10'", - "pytest-asyncio>=0.18.0", -] - -[project.urls] -Homepage = "https://codeflash.ai" - -[project.scripts] -codeflash = "codeflash.main:main" - -[project.optional-dependencies] - -[dependency-groups] -dev = [ - "ipython>=8.12.0", - "mypy>=1.13", - "ruff>=0.7.0", - "lxml-stubs>=0.5.1", - "pandas-stubs>=2.2.2.240807, <2.2.3.241009", - "types-Pygments>=2.18.0.20240506", - "types-colorama>=0.4.15.20240311", - "types-decorator>=5.1.8.20240310", - "types-jsonschema>=4.23.0.20240813", - "types-requests>=2.32.0.20241016", - "types-six>=1.16.21.20241009", - "types-cffi>=1.16.0.20240331", - "types-openpyxl>=3.1.5.20241020", - "types-regex>=2024.9.11.20240912", - "types-python-dateutil>=2.9.0.20241003", - "types-gevent>=24.11.0.20241230,<25", - "types-greenlet>=3.1.0.20241221,<4", - "types-pexpect>=4.9.0.20241208,<5", - "types-unidiff>=0.7.0.20240505,<0.8", - "prek>=0.2.25", - "ty>=0.0.14", - "uv>=0.9.29", -] -tests = [ - "black>=25.9.0", - "jax>=0.4.30", - "numpy>=2.0.2", - "pandas>=2.3.3", - "pyarrow>=15.0.0", - "pyrsistent>=0.20.0", - "scipy>=1.13.1", - "torch>=2.8.0", - "xarray>=2024.7.0", - "eval_type_backport", - "numba>=0.60.0", - "tensorflow>=2.20.0; python_version >= '3.10'", -] - -[tool.hatch.build.targets.sdist] -include = ["codeflash"] -exclude = [ - "docs/*", - "experiments/*", - "tests/*", - "*.pyc", - "__pycache__", - "*.pyo", - "*.pyd", - "*.so", - "*.dylib", - "*.dll", - "*.exe", - "*.log", - "*.tmp", - ".env", - ".env.*", - "**/.env", - "**/.env.*", - ".env.example", - "*.pem", - "*.key", - "secrets.*", - "config.yaml", - "config.json", - ".git", - ".gitignore", - ".gitattributes", - ".github", - "Dockerfile", - "docker-compose.yml", - "*.md", - "*.txt", - "*.csv", - "*.db", - "*.sqlite3", - "*.pdf", - "*.docx", - "*.xlsx", - "*.pptx", - "*.iml", - ".idea", - ".vscode", - ".DS_Store", - "Thumbs.db", - "venv", - "env", -] - -[tool.hatch.build.targets.wheel] -exclude = [ - "docs/*", - "experiments/*", - "tests/*", - "*.pyc", - "__pycache__", - "*.pyo", - "*.pyd", - "*.so", - "*.dylib", - "*.dll", - "*.exe", - "*.log", - "*.tmp", - ".env", - ".env.*", - "**/.env", - "**/.env.*", - ".env.example", - "*.pem", - "*.key", - "secrets.*", - "config.yaml", - "config.json", - ".git", - ".gitignore", - ".gitattributes", - ".github", - "Dockerfile", - "docker-compose.yml", - "*.md", - "*.txt", - "*.csv", - "*.db", - "*.sqlite3", - "*.pdf", - "*.docx", - "*.xlsx", - "*.pptx", - "*.iml", - ".idea", - ".vscode", - ".DS_Store", - "Thumbs.db", - "venv", - "env", - "codeflash/languages/java/resources/codeflash-runtime-*.jar", -] - -[tool.mypy] -show_error_code_links = true -pretty = true -show_absolute_path = true -show_error_context = true -show_error_end = true -strict = true -warn_unreachable = true -install_types = true -plugins = ["pydantic.mypy"] - -exclude = ["tests/", "code_to_optimize/", "pie_test_set/", "experiments/"] - -[[tool.mypy.overrides]] -module = ["jedi", "jedi.api.classes", "inquirer", "inquirer.themes", "numba"] -ignore_missing_imports = true - -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true - -[tool.ruff] -target-version = "py39" -line-length = 120 -fix = true -show-fixes = true -extend-exclude = ["code_to_optimize/", "pie_test_set/", "tests/", "experiments/"] - -[tool.ruff.lint] -select = ["ALL"] -ignore = [ - "N802", - "C901", - "D100", - "D101", - "D102", - "D103", - "D105", - "D107", - "D203", # incorrect-blank-line-before-class (incompatible with D211) - "D213", # multi-line-summary-second-line (incompatible with D212) - "S101", - "S603", - "S607", - "COM812", - "FIX002", - "PLR0912", - "PLR0913", - "PLR0915", - "TD002", - "TD003", - "TD004", - "PLR2004", - "UP007", # remove once we drop 3.9 support. - "E501", - "BLE001", - "ERA001", - "TRY003", - "EM101", - "T201", - "PGH004", - "S301", - "D104", - "PERF203", - "LOG015", - "PLC0415", - "UP045", - "TD007", - "D417", - "D401", - "S110", # try-except-pass - we do this a lot - "ARG002", # Unused method argument - # Added for multi-language branch - "FBT001", # Boolean positional argument - "FBT002", # Boolean default positional argument - "ANN401", # typing.Any disallowed - "ARG001", # Unused function argument (common in abstract/interface methods) - "TRY300", # Consider moving to else block - "FURB110", # if-exp-instead-of-or-operator - we prefer explicit if-else over "or" - "TRY401", # Redundant exception in logging.exception - "PLR0911", # Too many return statements - "PLW0603", # Global statement - "PLW2901", # Loop variable overwritten - "SIM102", # Nested if statements - "SIM103", # Return negated condition - "ANN001", # Missing type annotation - "PLC0206", # Dictionary items - "S314", # XML parsing (acceptable for dev tool) - "S608", # SQL injection (internal use only) - "S112", # try-except-continue - "PERF401", # List comprehension suggestion - "SIM108", # Ternary operator suggestion - "F841", # Unused variable (often intentional) - "ANN202", # Missing return type for private functions - "B009", # getattr-with-constant - needed to avoid mypy [misc] on dunder access - "PTH119", # os.path.basename — faster than Path().name for string paths -] - -[tool.ruff.lint.flake8-type-checking] -strict = true -runtime-evaluated-base-classes = ["pydantic.BaseModel"] -runtime-evaluated-decorators = ["pydantic.validate_call", "pydantic.dataclasses.dataclass"] - -[tool.ruff.lint.pep8-naming] -classmethod-decorators = [ - # Allow Pydantic's `@validator` decorator to trigger class method treatment. - "pydantic.validator", -] - -[tool.ruff.lint.isort] -split-on-trailing-comma = false - -[tool.ruff.format] -docstring-code-format = true -skip-magic-trailing-comma = true - -[tool.ty.src] -exclude = ["tests", "code_to_optimize", "pie_test_set", "experiments"] - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.uv] -workspace = { members = ["codeflash-benchmark"] } - -[tool.uv.sources] -codeflash-benchmark = { workspace = true } - -[tool.uv-dynamic-versioning] -enable = true -style = "pep440" -vcs = "git" - -[tool.hatch.build.hooks.version] -path = "codeflash/version.py" +[project] +name = "codeflash" +dynamic = ["version"] +description = "Client for codeflash.ai - automatic code performance optimization, powered by AI" +authors = [{ name = "CodeFlash Inc.", email = "contact@codeflash.ai" }] +requires-python = ">=3.9" +readme = "README.md" +license-files = ["LICENSE"] +keywords = [ + "codeflash", + "performance", + "optimization", + "ai", + "code", + "machine learning", + "LLM", +] +dependencies = [ + "unidiff>=0.7.4", + "pytest>=7.0.0", + "gitpython>=3.1.31", + "libcst>=1.0.1", + "jedi>=0.19.1", + # Tree-sitter for multi-language support + "tree-sitter>=0.23.0", + "tree-sitter-javascript>=0.23.0", + "tree-sitter-typescript>=0.23.0", + "tree-sitter-java>=0.23.0", + "tree-sitter-groovy>=0.1.0", + "tree-sitter-kotlin>=1.0.0", + "pytest-timeout>=2.1.0", + "tomlkit>=0.11.7", + "attrs>=23.1.0", + "requests>=2.28.0", + "junitparser>=3.1.0", + "pydantic>=1.10.1", + "humanize>=4.0.0", + "posthog>=3.0.0", + "click>=8.1.0", + "inquirer>=3.0.0", + "sentry-sdk>=1.40.6,<3.0.0", + "parameterized>=0.9.0", + "isort>=5.11.0", + "dill>=0.3.8", + "rich>=13.8.1", + "lxml>=5.3.0", + "crosshair-tool>=0.0.78; python_version < '3.15'", + "coverage>=7.6.4", + "line_profiler>=4.2.0", + "platformdirs>=4.3.7", + "pygls>=2.0.0,<3.0.0", + "codeflash-benchmark", + "filelock>=3.20.3; python_version >= '3.10'", + "filelock<3.20.3; python_version < '3.10'", + "pytest-asyncio>=0.18.0", +] + +[project.urls] +Homepage = "https://codeflash.ai" + +[project.scripts] +codeflash = "codeflash.main:main" + +[project.optional-dependencies] + +[dependency-groups] +dev = [ + "ipython>=8.12.0", + "mypy>=1.13", + "ruff>=0.7.0", + "lxml-stubs>=0.5.1", + "pandas-stubs>=2.2.2.240807, <2.2.3.241009", + "types-Pygments>=2.18.0.20240506", + "types-colorama>=0.4.15.20240311", + "types-decorator>=5.1.8.20240310", + "types-jsonschema>=4.23.0.20240813", + "types-requests>=2.32.0.20241016", + "types-six>=1.16.21.20241009", + "types-cffi>=1.16.0.20240331", + "types-openpyxl>=3.1.5.20241020", + "types-regex>=2024.9.11.20240912", + "types-python-dateutil>=2.9.0.20241003", + "types-gevent>=24.11.0.20241230,<25", + "types-greenlet>=3.1.0.20241221,<4", + "types-pexpect>=4.9.0.20241208,<5", + "types-unidiff>=0.7.0.20240505,<0.8", + "prek>=0.2.25", + "ty>=0.0.14", + "uv>=0.9.29", +] +tests = [ + "black>=25.9.0", + "jax>=0.4.30", + "numpy>=2.0.2", + "pandas>=2.3.3", + "pyarrow>=15.0.0", + "pyrsistent>=0.20.0", + "scipy>=1.13.1", + "torch>=2.8.0", + "xarray>=2024.7.0", + "eval_type_backport", + "numba>=0.60.0", + "tensorflow>=2.20.0; python_version >= '3.10'", +] + +[tool.hatch.build.targets.sdist] +include = ["codeflash"] +exclude = [ + "docs/*", + "experiments/*", + "tests/*", + "*.pyc", + "__pycache__", + "*.pyo", + "*.pyd", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + "*.log", + "*.tmp", + ".env", + ".env.*", + "**/.env", + "**/.env.*", + ".env.example", + "*.pem", + "*.key", + "secrets.*", + "config.yaml", + "config.json", + ".git", + ".gitignore", + ".gitattributes", + ".github", + "Dockerfile", + "docker-compose.yml", + "*.md", + "*.txt", + "*.csv", + "*.db", + "*.sqlite3", + "*.pdf", + "*.docx", + "*.xlsx", + "*.pptx", + "*.iml", + ".idea", + ".vscode", + ".DS_Store", + "Thumbs.db", + "venv", + "env", +] + +[tool.hatch.build.targets.wheel] +exclude = [ + "docs/*", + "experiments/*", + "tests/*", + "*.pyc", + "__pycache__", + "*.pyo", + "*.pyd", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + "*.log", + "*.tmp", + ".env", + ".env.*", + "**/.env", + "**/.env.*", + ".env.example", + "*.pem", + "*.key", + "secrets.*", + "config.yaml", + "config.json", + ".git", + ".gitignore", + ".gitattributes", + ".github", + "Dockerfile", + "docker-compose.yml", + "*.md", + "*.txt", + "*.csv", + "*.db", + "*.sqlite3", + "*.pdf", + "*.docx", + "*.xlsx", + "*.pptx", + "*.iml", + ".idea", + ".vscode", + ".DS_Store", + "Thumbs.db", + "venv", + "env", + "codeflash/languages/java/resources/codeflash-runtime-*.jar", +] + +[tool.mypy] +show_error_code_links = true +pretty = true +show_absolute_path = true +show_error_context = true +show_error_end = true +strict = true +warn_unreachable = true +install_types = true +plugins = ["pydantic.mypy"] + +exclude = ["tests/", "code_to_optimize/", "pie_test_set/", "experiments/"] + +[[tool.mypy.overrides]] +module = ["jedi", "jedi.api.classes", "inquirer", "inquirer.themes", "numba"] +ignore_missing_imports = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.ruff] +target-version = "py39" +line-length = 120 +fix = true +show-fixes = true +extend-exclude = ["code_to_optimize/", "pie_test_set/", "tests/", "experiments/"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "N802", + "C901", + "D100", + "D101", + "D102", + "D103", + "D105", + "D107", + "D203", # incorrect-blank-line-before-class (incompatible with D211) + "D213", # multi-line-summary-second-line (incompatible with D212) + "S101", + "S603", + "S607", + "COM812", + "FIX002", + "PLR0912", + "PLR0913", + "PLR0915", + "TD002", + "TD003", + "TD004", + "PLR2004", + "UP007", # remove once we drop 3.9 support. + "E501", + "BLE001", + "ERA001", + "TRY003", + "EM101", + "T201", + "PGH004", + "S301", + "D104", + "PERF203", + "LOG015", + "PLC0415", + "UP045", + "TD007", + "D417", + "D401", + "S110", # try-except-pass - we do this a lot + "ARG002", # Unused method argument + # Added for multi-language branch + "FBT001", # Boolean positional argument + "FBT002", # Boolean default positional argument + "ANN401", # typing.Any disallowed + "ARG001", # Unused function argument (common in abstract/interface methods) + "TRY300", # Consider moving to else block + "FURB110", # if-exp-instead-of-or-operator - we prefer explicit if-else over "or" + "TRY401", # Redundant exception in logging.exception + "PLR0911", # Too many return statements + "PLW0603", # Global statement + "PLW2901", # Loop variable overwritten + "SIM102", # Nested if statements + "SIM103", # Return negated condition + "ANN001", # Missing type annotation + "PLC0206", # Dictionary items + "S314", # XML parsing (acceptable for dev tool) + "S608", # SQL injection (internal use only) + "S112", # try-except-continue + "PERF401", # List comprehension suggestion + "SIM108", # Ternary operator suggestion + "F841", # Unused variable (often intentional) + "ANN202", # Missing return type for private functions + "B009", # getattr-with-constant - needed to avoid mypy [misc] on dunder access + "PTH119", # os.path.basename — faster than Path().name for string paths +] + +[tool.ruff.lint.flake8-type-checking] +strict = true +runtime-evaluated-base-classes = ["pydantic.BaseModel"] +runtime-evaluated-decorators = ["pydantic.validate_call", "pydantic.dataclasses.dataclass"] + +[tool.ruff.lint.pep8-naming] +classmethod-decorators = [ + # Allow Pydantic's `@validator` decorator to trigger class method treatment. + "pydantic.validator", +] + +[tool.ruff.lint.isort] +split-on-trailing-comma = false + +[tool.ruff.format] +docstring-code-format = true +skip-magic-trailing-comma = true + +[tool.ty.src] +exclude = ["tests", "code_to_optimize", "pie_test_set", "experiments"] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv] +workspace = { members = ["codeflash-benchmark"] } + +[tool.uv.sources] +codeflash-benchmark = { workspace = true } + +[tool.uv-dynamic-versioning] +enable = true +style = "pep440" +vcs = "git" + +[tool.hatch.build.hooks.version] +path = "codeflash/version.py" template = """# These version placeholders will be replaced by uv-dynamic-versioning during build. __version__ = "{version}" -""" - - -#[tool.hatch.build.hooks.custom] -#path = "codeflash/update_license_version.py" - - -[tool.codeflash] -# All paths are relative to this pyproject.toml's directory. -module-root = "codeflash" -tests-root = "tests" -benchmarks-root = "tests/benchmarks" -ignore-paths = [] -formatter-cmds = [ - "uvx ruff check --exit-zero --fix $file", - "uvx ruff format $file", -] - -[tool.pytest.ini_options] -filterwarnings = [ - "ignore::pytest.PytestCollectionWarning", -] -markers = [ - "ci_skip: mark test to skip in CI environment", -] - - -[build-system] -requires = ["hatchling", "uv-dynamic-versioning"] -build-backend = "hatchling.build" - +""" + + +#[tool.hatch.build.hooks.custom] +#path = "codeflash/update_license_version.py" + + +[tool.codeflash] +# All paths are relative to this pyproject.toml's directory. +module-root = "codeflash" +tests-root = "tests" +benchmarks-root = "tests/benchmarks" +ignore-paths = [] +formatter-cmds = [ + "uvx ruff check --exit-zero --fix $file", + "uvx ruff format $file", +] + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::pytest.PytestCollectionWarning", +] +markers = [ + "ci_skip: mark test to skip in CI environment", +] + + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + diff --git a/uv.lock b/uv.lock index b2cea32e2..31fde63ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1119,7 +1119,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -3762,7 +3762,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [