diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96da776..7a714fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Build package run: uv build diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5d71f1d..c94e401 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -69,7 +69,9 @@ jobs: run: uv run every-python run v3.13.0 -- --version - name: Test build with JIT - run: uv run every-python install main --jit --verbose + # Use commit that I know has JIT support with LLVM 20 + run: uv run every-python install 42d014086098d3d70cacb4d8993f04cace120c12 --jit --verbose - name: Verify JIT build works - run: uv run every-python run main --jit -- --version + # Use commit that I know has JIT support with LLVM 20 + run: uv run every-python run 42d014086098d3d70cacb4d8993f04cace120c12 --jit -- --version \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 430550f..f02c0db 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: uv sync diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 06f0e32..08eda98 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -30,7 +30,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: uv sync --all-groups diff --git a/.gitignore b/.gitignore index 4ad3f4c..2606580 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ build/ # Pytest .cache/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +test_jit_api.py \ No newline at end of file diff --git a/every_python/__init__.py b/every_python/__init__.py index 472039f..c02a11e 100644 --- a/every_python/__init__.py +++ b/every_python/__init__.py @@ -1,3 +1,3 @@ -"""every-python - A utility for building any commit of CPython.""" +"""every-python - A utility for building and running any commit of CPython.""" __version__ = "0.2.0" diff --git a/every_python/main.py b/every_python/main.py index 27440cf..365cbe8 100644 --- a/every_python/main.py +++ b/every_python/main.py @@ -1,11 +1,14 @@ +import multiprocessing import os import platform import shutil import subprocess +from datetime import datetime from pathlib import Path import typer from rich.console import Console +from rich.progress import Progress, TaskID from rich.table import Table from typing_extensions import Annotated @@ -13,9 +16,10 @@ from every_python.runner import CommandResult, CommandRunner, get_runner from every_python.utils import ( BuildInfo, - python_binary_location, + BuildVersion, check_llvm_available, get_llvm_version_for_commit, + python_binary_location, ) app = typer.Typer() @@ -27,7 +31,7 @@ CPYTHON_REPO = "https://github.com/python/cpython.git" -def ensure_repo() -> Path: +def _ensure_repo() -> Path: """Ensure CPython repo exists as a blobless clone.""" if not REPO_DIR.exists(): runner: CommandRunner = get_runner() @@ -54,9 +58,9 @@ def ensure_repo() -> Path: return REPO_DIR -def resolve_ref(ref: str) -> str: +def _resolve_ref(ref: str) -> str: """Resolve a git ref (tag, branch, commit) to a full commit hash.""" - ensure_repo() + _ensure_repo() runner = get_runner() output = get_output() @@ -80,51 +84,177 @@ def resolve_ref(ref: str) -> str: return result.stdout.strip() +def _show_llvm_install_instructions(llvm_version: str) -> None: + """Show instructions to install LLVM on the current platform.""" + output = get_output() + if platform.system() == "Darwin": + output.info(f"Install with: brew install llvm@{llvm_version}") + elif platform.system() == "Linux": + output.info( + f"Install with: apt install llvm-{llvm_version} clang-{llvm_version} lld-{llvm_version}" + ) + else: + output.info( + f"Install LLVM {llvm_version} from https://github.com/llvm/llvm-project/releases" + ) + + +def _validate_jit_availability(commit: str, repo_dir: Path) -> bool: + """Check if JIT can be enabled for the given commit.""" + output = get_output() + llvm_version = get_llvm_version_for_commit(commit, repo_dir) + + if not llvm_version: + output.warning("Warning: JIT not available in this commit") + if not typer.confirm("Continue building without JIT?", default=True): + raise typer.Exit(0) + return False + + if not check_llvm_available(llvm_version): + output.warning(f"Warning: LLVM {llvm_version} not found") + _show_llvm_install_instructions(llvm_version) + if not typer.confirm("Continue building without JIT?", default=True): + raise typer.Exit(0) + return False + + output.status(f"Building with JIT (LLVM {llvm_version})") + return True + + +def _get_configure_args(build_dir: Path, enable_jit: bool) -> list[str]: + """Get platform-specific configure arguments.""" + if platform.system() == "Windows": + args = ["cmd", "/c", "PCbuild\\build.bat", "-c", "Debug"] + if enable_jit: + args.append("--experimental-jit") + return args + + args = ["./configure", "--prefix", str(build_dir), "--with-pydebug"] + if enable_jit: + args.append("--enable-experimental-jit") + return args + + +def _run_configure( + runner: CommandRunner, + build_dir: Path, + enable_jit: bool, + verbose: bool, + progress: Progress, + task: TaskID, +) -> None: + """Run the configure step.""" + output = get_output() + configure_args = _get_configure_args(build_dir, enable_jit) + + if verbose: + progress.stop() + output.status(f"Running: {' '.join(configure_args)}") + else: + progress.update(task, description="Configuring build...") + + result = runner.run(configure_args, cwd=REPO_DIR, capture_output=not verbose) + + if not result.success: + if not verbose: + progress.stop() + output.error(f"Configure failed: {result.stderr if not verbose else ''}") + raise typer.Exit(1) + + +def _build_and_install_windows( + build_dir: Path, verbose: bool, progress: Progress, task: TaskID +) -> None: + """Build and install on Windows by copying PCbuild output.""" + output = get_output() + progress.update(task, description="Copying build artifacts...") + + # Find build output directory + pcbuild_dir = REPO_DIR / "PCbuild" / "amd64" + if not pcbuild_dir.exists(): + pcbuild_dir = REPO_DIR / "PCbuild" / "win32" + + if not pcbuild_dir.exists(): + progress.stop() + output.error("Build output not found in PCbuild directory") + raise typer.Exit(1) + + build_dir.mkdir(parents=True, exist_ok=True) + + if verbose: + output.status(f"Copying from {pcbuild_dir} to {build_dir}") + + shutil.copytree(pcbuild_dir, build_dir, dirs_exist_ok=True) + + +def _build_and_install_unix( + runner: CommandRunner, verbose: bool, progress: Progress, task: TaskID +) -> None: + """Build and install on Unix systems using make.""" + output = get_output() + ncpu = multiprocessing.cpu_count() + + if verbose: + output.status(f"Building with {ncpu} cores (this may take a few minutes)...") + output.status(f"Running: make -j{ncpu}") + else: + progress.update( + task, + description=f"Building with {ncpu} cores (this may take a few minutes)...", + ) + + # Build + make_result = runner.run( + ["make", f"-j{ncpu}"], cwd=REPO_DIR, capture_output=not verbose + ) + + if not make_result.success: + if not verbose: + progress.stop() + output.error(f"Build failed: {make_result.stderr if not verbose else ''}") + raise typer.Exit(1) + + # Install + progress.update(task, description="Installing...") + install_result = runner.run(["make", "install"], cwd=REPO_DIR) + + if not install_result.success: + progress.stop() + output.error(f"Install failed: {install_result.stderr}") + raise typer.Exit(1) + + def build_python(commit: str, enable_jit: bool = False, verbose: bool = False) -> Path: """Build Python at the given commit.""" - ensure_repo() + _ensure_repo() runner = get_runner() output = get_output() # Check JIT availability if requested if enable_jit: - llvm_version = get_llvm_version_for_commit(commit, REPO_DIR) - - if not llvm_version: - output.warning("Warning: JIT not available in this commit") - if not typer.confirm("Continue building without JIT?", default=True): - raise typer.Exit(0) - enable_jit = False - elif not check_llvm_available(llvm_version): - output.warning(f"Warning: LLVM {llvm_version} not found") - if platform.system() == "Darwin": - output.info(f"Install with: brew install llvm@{llvm_version}") - elif platform.system() == "Linux": - output.info( - f"Install with: apt install llvm-{llvm_version} clang-{llvm_version} lld-{llvm_version}" - ) - else: # Windows - output.info( - f"Install LLVM {llvm_version} from https://github.com/llvm/llvm-project/releases" - ) - if not typer.confirm("Continue building without JIT?", default=True): - raise typer.Exit(0) - enable_jit = False - else: - output.status(f"Building with JIT (LLVM {llvm_version})") + enable_jit = _validate_jit_availability(commit, REPO_DIR) # Determine build directory based on final JIT flag (after availability checks) build_info = BuildInfo(commit=commit, jit_enabled=enable_jit) build_dir = build_info.get_path(BUILDS_DIR) + # Check if we have a complete cached build if build_dir.exists(): - output.success( - f"Build {commit[:7]}{build_info.suffix} already exists, skipping build" - ) - return build_dir + python_bin = python_binary_location(BUILDS_DIR, build_info) + if python_bin.exists(): + output.success( + f"Build {commit[:7]}{build_info.suffix} already exists, skipping build" + ) + return build_dir + else: + # Incomplete build - clean it up and rebuild + output.warning( + f"Incomplete build detected for {commit[:7]}{build_info.suffix}, cleaning and rebuilding..." + ) + shutil.rmtree(build_dir) with create_progress(console) as progress: - # Checkout the commit + # Checkout task = progress.add_task(f"Checking out {commit[:7]}...", total=None) result = runner.run_git(["checkout", commit], REPO_DIR) @@ -134,107 +264,21 @@ def build_python(commit: str, enable_jit: bool = False, verbose: bool = False) - raise typer.Exit(1) # Configure - progress.update(task, description="Configuring build...") + _run_configure(runner, build_dir, enable_jit, verbose, progress, task) + # Build and install (platform-specific) if platform.system() == "Windows": - # Windows build uses PCbuild\build.bat via cmd - configure_args = ["cmd", "/c", "PCbuild\\build.bat", "-c", "Debug"] - if enable_jit: - configure_args.append("--experimental-jit") + _build_and_install_windows(build_dir, verbose, progress, task) else: - configure_args = [ - "./configure", - "--prefix", - str(build_dir), - "--with-pydebug", - ] - - # Add JIT flag if enabled - if enable_jit: - configure_args.append("--enable-experimental-jit") - if verbose: - progress.stop() - output.status(f"Running: {' '.join(configure_args)}") - - configure_result = runner.run( - configure_args, - cwd=REPO_DIR, - capture_output=not verbose, - ) + _build_and_install_unix(runner, verbose, progress, task) - if not configure_result.success: - if not verbose: - progress.stop() - output.error( - f"Configure failed: {configure_result.stderr if not verbose else ''}" - ) + # Validate that the build produced a Python binary + python_bin = python_binary_location(BUILDS_DIR, build_info) + if not python_bin.exists(): + progress.stop() + output.error(f"Build completed but Python binary not found at {python_bin}") raise typer.Exit(1) - # Build and install - import multiprocessing - - ncpu = multiprocessing.cpu_count() - - if platform.system() == "Windows": - # Windows: build.bat does both build and "install" (outputs to PCbuild/amd64 or PCbuild/win32) - # The configure step above already ran build.bat, so we're done - # Just copy the output to our build directory - progress.update(task, description="Copying build artifacts...") - import shutil - - # Try both amd64 and win32 architectures - pcbuild_dir = REPO_DIR / "PCbuild" / "amd64" - if not pcbuild_dir.exists(): - pcbuild_dir = REPO_DIR / "PCbuild" / "win32" - - if not pcbuild_dir.exists(): - progress.stop() - output.error("Build output not found in PCbuild directory") - raise typer.Exit(1) - - build_dir.mkdir(parents=True, exist_ok=True) - - if verbose: - output.status(f"Copying from {pcbuild_dir} to {build_dir}") - - shutil.copytree(pcbuild_dir, build_dir, dirs_exist_ok=True) - else: - # Unix: use make - if verbose: - output.status(f"Building with {ncpu} cores (this may a few minutes)...") - output.status(f"Running: make -j{ncpu}") - else: - progress.update( - task, - description=f"Building with {ncpu} cores (this may a few minutes)...", - ) - - make_result = runner.run( - ["make", f"-j{ncpu}"], - cwd=REPO_DIR, - capture_output=not verbose, - ) - - if not make_result.success: - if not verbose: - progress.stop() - output.error( - f"Build failed: {make_result.stderr if not verbose else ''}" - ) - raise typer.Exit(1) - - # Install to prefix - progress.update(task, description="Installing...") - install_result: CommandResult = runner.run( - ["make", "install"], - cwd=REPO_DIR, - ) - - if not install_result.success: - progress.stop() - output.error(f"Install failed: {install_result.stderr}") - raise typer.Exit(1) - progress.update(task, description=f"[green]Built {commit[:7]}[/green]") return build_dir @@ -256,7 +300,7 @@ def install( """Build and install a specific CPython version.""" output = get_output() try: - commit = resolve_ref(ref) + commit = _resolve_ref(ref) output.info(f"Resolved '{ref}' to commit {commit[:7]}") build_dir = build_python(commit, enable_jit=jit, verbose=verbose) @@ -288,7 +332,7 @@ def run( """Run a command with a specific Python version.""" output = get_output() try: - commit = resolve_ref(ref) + commit = _resolve_ref(ref) build_info = BuildInfo(commit=commit, jit_enabled=jit) build_dir = build_info.get_path(BUILDS_DIR) @@ -330,7 +374,7 @@ def list_builds(): # Get version info for all builds runner = get_runner() - builds_with_version: list[tuple[Path, str, BuildInfo]] = [] + build_versions: list[BuildVersion] = [] for build in BUILDS_DIR.iterdir(): build_info = BuildInfo.from_directory(build) python_bin = build / "bin" / "python3" @@ -341,37 +385,20 @@ def list_builds(): else: version = "unknown" - builds_with_version.append((build, version, build_info)) - - # Sort by version (descending), then by JIT status (non-JIT first) - def parse_version(version_str: str) -> tuple[int, int, int, str]: - """Parse version string into sortable tuple.""" - if version_str == "unknown": - return (0, 0, 0, "") - - # Extract "Python X.Y.Z" or "Python X.Y.Za1+" - import re - - match = re.search(r"Python (\d+)\.(\d+)\.(\d+)([a-z0-9+]*)", version_str) - if match: - major = int(match.group(1)) - minor = int(match.group(2)) - micro = int(match.group(3)) - suffix = match.group(4) - return (major, minor, micro, suffix) - return (0, 0, 0, "") + build_versions.append(BuildVersion.from_build(build, version, build_info)) - builds_with_version.sort( + # Parse versions once and sort + build_versions.sort( key=lambda x: ( - -parse_version(x[1])[0], - -parse_version(x[1])[1], - -parse_version(x[1])[2], - parse_version(x[1])[3], - x[2].jit_enabled, - ) + x.major, + x.minor, + x.micro, + x.suffix, + not x.build_info.jit_enabled, + ), + reverse=True, ) - # Create Rich table table = Table(show_header=True, header_style="bold") table.add_column("Version", style="cyan") table.add_column("JIT", justify="center", width=4) @@ -379,41 +406,41 @@ def parse_version(version_str: str) -> tuple[int, int, int, str]: table.add_column("Commit", style="white", width=7) table.add_column("Message", style="dim", no_wrap=False) - from datetime import datetime - - for build, version, build_info in builds_with_version: - # Get commit timestamp and message - commit_info_result = runner.run_git( - ["log", "-1", "--format=%at|%s", build_info.commit], - REPO_DIR, - ) + commits = [bv.build_info.commit for bv in build_versions] - if commit_info_result.success and commit_info_result.stdout.strip(): - parts = commit_info_result.stdout.strip().split("|", 1) - commit_timestamp = int(parts[0]) - commit_msg = parts[1] if len(parts) > 1 else "" - timestamp = datetime.fromtimestamp(commit_timestamp).strftime( - "%Y-%m-%d %H:%M" - ) + result = runner.run_git( + ["log", "--format=%H|%at|%s", "--no-walk"] + commits, repo_dir=REPO_DIR + ) + commit_info: dict[str, tuple[int, str]] = {} + for line in result.stdout.strip().split("\n"): + parts = line.split("|", 2) + if len(parts) == 3: + hash_val, timestamp_str, msg_val = parts + commit_info[hash_val] = (int(timestamp_str), msg_val) + + for bv in build_versions: + if bv.build_info.commit in commit_info: + ts, msg = commit_info[bv.build_info.commit] + timestamp = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") else: timestamp = "unknown" - commit_msg = "" + msg = "" - if version != "unknown": - jit_text = jit_indicator() if build_info.jit_enabled else "" + if bv.version_string != "unknown": + jit_text = jit_indicator() if bv.build_info.jit_enabled else "" table.add_row( - version.replace("Python ", ""), + bv.version_string.replace("Python ", ""), jit_text, timestamp, - build_info.commit[:7], - commit_msg, + bv.build_info.commit[:7], + msg, ) else: table.add_row( "[red]incomplete[/red]", "", timestamp, - build_info.commit[:7], + bv.build_info.commit[:7], "", ) @@ -435,9 +462,8 @@ def clean( output.warning("No builds to remove") elif ref: try: - commit = resolve_ref(ref) + commit = _resolve_ref(ref) - # Check for both JIT and non-JIT builds removed: list[str] = [] for jit_enabled in [False, True]: build_info = BuildInfo(commit=commit, jit_enabled=jit_enabled) @@ -479,18 +505,18 @@ def bisect( Example: every-python bisect --good v3.13.0 --bad main --run "python test.py" """ - ensure_repo() + _ensure_repo() runner = get_runner() output = get_output() try: # Resolve refs to commits output.info(f"\nResolving good commit: {good}") - good_commit = resolve_ref(good) + good_commit = _resolve_ref(good) output.info(f" → {good_commit[:7]}") output.info(f"Resolving bad commit: {bad}") - bad_commit = resolve_ref(bad) + bad_commit = _resolve_ref(bad) output.info(f" → {bad_commit[:7]}") # Start bisect @@ -546,96 +572,65 @@ def is_bisect_done() -> bool: f"\n[bold cyan]Testing commit {current_commit[:7]}...[/bold cyan]" ) - # Build this commit + # Build this commit (build_python handles incomplete builds internally) try: - build_dir = build_python(current_commit, enable_jit=jit) - python_bin = build_dir / "bin" / "python3" - - if not python_bin.exists(): - # Build directory exists but python binary is missing - incomplete build - output.warning( - "Incomplete build detected, cleaning and rebuilding..." - ) - shutil.rmtree(build_dir) + build_python(current_commit, enable_jit=jit) + build_info = BuildInfo(commit=current_commit, jit_enabled=jit) + python_bin = python_binary_location(BUILDS_DIR, build_info) + except typer.Exit: + # Build failed - skip this commit in bisect + output.error("Build failed, skipping commit (exit 125)") + runner.run_git(["bisect", "skip"], REPO_DIR, check=True) + continue + + # Run the test command + output.info(f"Running: {run}") + # Note: using subprocess directly here since we need shell=True + test_result_raw = subprocess.run( + run, + shell=True, + cwd=Path.cwd(), + env={**os.environ, "PYTHON": str(python_bin)}, + ) + test_result = CommandResult( + returncode=test_result_raw.returncode, + stdout="", + stderr="", + ) - # Retry build - try: - build_dir = build_python(current_commit, enable_jit=jit) - python_bin = build_dir / "bin" / "python3" - - if not python_bin.exists(): - output.error( - "Build failed after retry, skipping commit (exit 125)" - ) - runner.run_git(["bisect", "skip"], REPO_DIR, check=True) - continue - except Exception: - output.error("Build failed, skipping commit (exit 125)") - runner.run_git(["bisect", "skip"], REPO_DIR, check=True) - continue - - # Run the test command - output.info(f"Running: {run}") - # Note: using subprocess directly here since we need shell=True - test_result_raw = subprocess.run( - run, - shell=True, - cwd=Path.cwd(), - env={**os.environ, "PYTHON": str(python_bin)}, - ) - test_result = CommandResult( - returncode=test_result_raw.returncode, - stdout="", - stderr="", + if test_result.returncode == 0: + output.success("Test passed (exit 0) - marking as good") + bisect_result = runner.run_git(["bisect", "good"], REPO_DIR, check=True) + elif test_result.returncode == 125: + output.warning("Test requested skip (exit 125) - skipping commit") + bisect_result = runner.run_git(["bisect", "skip"], REPO_DIR, check=True) + elif 1 <= test_result.returncode < 128: + output.error( + f"✗ Test failed (exit {test_result.returncode}) - marking as bad" ) + bisect_result = runner.run_git(["bisect", "bad"], REPO_DIR, check=True) + else: + output.error(f"Test exited with code {test_result.returncode} >= 128") + raise typer.Exit(1) - # Handle exit codes like every-ts - if test_result.returncode == 0: - output.success("Test passed (exit 0) - marking as good") - bisect_result = runner.run_git( - ["bisect", "good"], REPO_DIR, check=True - ) - elif test_result.returncode == 125: - output.warning("Test requested skip (exit 125) - skipping commit") - bisect_result = runner.run_git( - ["bisect", "skip"], REPO_DIR, check=True - ) - elif 1 <= test_result.returncode < 128: - output.error( - f"✗ Test failed (exit {test_result.returncode}) - marking as bad" - ) - bisect_result = runner.run_git( - ["bisect", "bad"], REPO_DIR, check=True - ) - else: - output.error( - f"Test exited with code {test_result.returncode} >= 128" - ) - raise typer.Exit(1) - - # Check if bisect completed - if "is the first bad commit" in bisect_result.stdout: - break + # Check if bisect completed + if "is the first bad commit" in bisect_result.stdout: + break - # Show steps remaining after each bisect step - if "Bisecting:" in bisect_result.stdout: - import re + # Show steps remaining after each bisect step + if "Bisecting:" in bisect_result.stdout: + import re - match = re.search( - r"Bisecting: (\d+) revisions? left.*?\(roughly (\d+) steps?\)", - bisect_result.stdout, + match = re.search( + r"Bisecting: (\d+) revisions? left.*?\(roughly (\d+) steps?\)", + bisect_result.stdout, + ) + if match: + revisions = match.group(1) + steps = match.group(2) + output.info( + f"[dim]→ {revisions} revisions left (roughly {steps} steps)[/dim]" ) - if match: - revisions = match.group(1) - steps = match.group(2) - output.info( - f"[dim]→ {revisions} revisions left (roughly {steps} steps)[/dim]" - ) - - except Exception as e: - output.error(f"Error during bisect: {e}") - output.info("Skipping commit...") - runner.run_git(["bisect", "skip"], REPO_DIR, check=True) # Show final result result = runner.run_git(["bisect", "log"], REPO_DIR) diff --git a/every_python/utils.py b/every_python/utils.py index d6e69c5..c64f959 100644 --- a/every_python/utils.py +++ b/every_python/utils.py @@ -197,7 +197,7 @@ def python_binary_location(builds_dir: Path, build_info: "BuildInfo") -> Path: @dataclass class BuildInfo: - """Information about a Python build.""" + """Information needed to build and locate a specific Python build.""" commit: str jit_enabled: bool @@ -227,3 +227,49 @@ def from_directory_name(cls, name: str) -> "BuildInfo": def from_directory(cls, path: Path) -> "BuildInfo": """Parse build info from directory path.""" return cls.from_directory_name(path.name) + + +@dataclass +class BuildVersion: + """Complete build info with version parsing.""" + + build_info: BuildInfo + build_path: Path + version_string: str + major: int + minor: int + micro: int + suffix: str + + @staticmethod + def parse_version(version_str: str) -> tuple[int, int, int, str]: + """Parse version string into sortable tuple.""" + if version_str == "unknown": + return (0, 0, 0, "") + + # Extract "Python X.Y.Z" or "Python X.Y.Za1+" + match = re.search(r"Python (\d+)\.(\d+)\.(\d+)([a-z0-9+]*)", version_str) + if match: + return ( + int(match.group(1)), + int(match.group(2)), + int(match.group(3)), + match.group(4), + ) + return (0, 0, 0, "") + + @classmethod + def from_build( + cls, build: Path, version: str, build_info: BuildInfo + ) -> "BuildVersion": + """Create BuildVersion from build path and version string.""" + parsed = cls.parse_version(version) + return cls( + build_info=build_info, + build_path=build, + version_string=version, + major=parsed[0], + minor=parsed[1], + micro=parsed[2], + suffix=parsed[3], + ) diff --git a/pyproject.toml b/pyproject.toml index 3bcda21..772e6a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ { name = "Savannah Ostrowski", email = "savannah@python.org" } ] license = { text = "MIT" } -requires-python = ">=3.13" +requires-python = ">=3.14" dependencies = [ "typer>=0.20.0", "rich>=13.0.0", @@ -67,4 +67,4 @@ select = ["I"] # Enable isort rules for import sorting [tool.pyright] include = ["every_python"] typeCheckingMode = "strict" -pythonVersion = "3.13" \ No newline at end of file +pythonVersion = "3.14" \ No newline at end of file diff --git a/test_jit_api.py b/test_jit_api.py deleted file mode 100644 index 9b04496..0000000 --- a/test_jit_api.py +++ /dev/null @@ -1,6 +0,0 @@ -import sys -# Exit 0 (good) = feature doesn't exist yet -# Exit 1 (bad) = feature exists -if hasattr(sys, "_jit"): - sys.exit(1) # Feature exists - mark as "bad" -sys.exit(0) # Feature doesn't exist - mark as "good" \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py index fe71d80..7e3e8ab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,9 +6,9 @@ from typer.testing import CliRunner from every_python.main import ( + _ensure_repo, + _resolve_ref, app, - ensure_repo, - resolve_ref, ) from every_python.output import set_output from every_python.runner import set_runner @@ -35,7 +35,7 @@ def test_clone_if_not_exists(self, mock_run: Mock, tmp_path: Path): with patch("every_python.main.REPO_DIR", repo_dir): mock_run.return_value = Mock(returncode=0, stdout="", stderr="") - ensure_repo() + _ensure_repo() # Should have called git clone assert any( @@ -51,7 +51,7 @@ def test_skip_clone_if_exists(self, tmp_path: Path): patch("every_python.main.REPO_DIR", repo_dir), patch("subprocess.run") as mock_run, ): - ensure_repo() + _ensure_repo() mock_run.assert_not_called() @@ -69,7 +69,7 @@ def test_resolve_main(self, mock_run: Mock, tmp_path: Path): mock_run.return_value = Mock( returncode=0, stdout="abc123def456\n", stderr="" ) - commit = resolve_ref("main") + commit = _resolve_ref("main") assert commit == "abc123def456" assert "rev-parse" in str(mock_run.call_args) @@ -84,7 +84,7 @@ def test_resolve_tag(self, mock_run: Mock, tmp_path: Path): mock_run.return_value = Mock( returncode=0, stdout="def456abc123\n", stderr="" ) - commit = resolve_ref("v3.13.0") + commit = _resolve_ref("v3.13.0") assert commit == "def456abc123" @@ -101,14 +101,14 @@ def test_resolve_invalid_ref(self, mock_run: Mock, tmp_path: Path): from click.exceptions import Exit with pytest.raises(Exit): - resolve_ref("invalid-ref") + _resolve_ref("invalid-ref") class TestInstallCommand: """Test the install command.""" @patch("every_python.main.build_python") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") def test_install_main(self, mock_resolve: Mock, mock_build: Mock, tmp_path: Path): """Test installing main branch.""" mock_resolve.return_value = "abc123def456" @@ -124,7 +124,7 @@ def test_install_main(self, mock_resolve: Mock, mock_build: Mock, tmp_path: Path ) @patch("every_python.main.build_python") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") def test_install_with_jit( self, mock_resolve: Mock, mock_build: Mock, tmp_path: Path ): @@ -140,7 +140,7 @@ def test_install_with_jit( ) @patch("every_python.main.build_python") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") def test_install_verbose( self, mock_resolve: Mock, mock_build: Mock, tmp_path: Path ): @@ -160,7 +160,7 @@ class TestRunCommand: """Test the run command.""" @patch("os.execv") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") @patch("platform.system") def test_run_existing_build( self, mock_platform: Mock, mock_resolve: Mock, mock_execv: Mock, tmp_path: Path @@ -184,7 +184,7 @@ def test_run_existing_build( assert "python3" in args[0] @patch("every_python.main.build_python") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") @patch("os.execv") def test_run_triggers_build( self, mock_execv: Mock, mock_resolve: Mock, mock_build: Mock, tmp_path: Path @@ -254,7 +254,12 @@ def mock_run_side_effect(*args: Any, **kwargs: Any) -> Mock: if "--version" in cmd: return Mock(returncode=0, stdout="Python 3.14.0a1+", stderr="") elif "git" in cmd and "log" in cmd: - return Mock(returncode=0, stdout="1234567890|Test commit", stderr="") + # Format: hash|timestamp|subject (matching --format=%H|%at|%s) + return Mock( + returncode=0, + stdout="abc123d|1234567890|Test commit\ndef456a|1234567891|Test commit JIT", + stderr="", + ) return Mock(returncode=0, stdout="", stderr="") mock_run.side_effect = mock_run_side_effect @@ -278,14 +283,14 @@ def test_clean_specific_build(self, tmp_path: Path): builds_dir = tmp_path / "builds" builds_dir.mkdir(parents=True) - # Create builds - use the full commit hash that resolve_ref will return + # Create builds - use the full commit hash that _resolve_ref will return (builds_dir / "abc123def456").mkdir() (builds_dir / "abc123def456-jit").mkdir() (builds_dir / "def456a").mkdir() with ( patch("every_python.main.BUILDS_DIR", builds_dir), - patch("every_python.main.resolve_ref", return_value="abc123def456"), + patch("every_python.main._resolve_ref", return_value="abc123def456"), ): result = runner.invoke(app, ["clean", "main"]) @@ -312,7 +317,7 @@ class TestBisectCommand: """Test the bisect command.""" @patch("subprocess.run") - @patch("every_python.main.resolve_ref") + @patch("every_python.main._resolve_ref") @patch("every_python.main.build_python") def test_bisect_basic( self, mock_build: Mock, mock_resolve: Mock, mock_run: Mock, tmp_path: Path @@ -324,7 +329,7 @@ def test_bisect_basic( builds_dir = tmp_path / "builds" - def resolve_side_effect(ref): + def resolve_side_effect(ref: str) -> str: if ref == "good-ref": return "abc123d" elif ref == "bad-ref": diff --git a/uv.lock b/uv.lock index f89bb4f..6a1ab33 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.14" [[package]] name = "click"