From d3bd8fdd67abb37d580ee6e8dd03890c2e11d4b8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:33:41 +0000 Subject: [PATCH 1/2] feat: Add automated pytest slow marker tuner - Add CLI tool for automatically tuning pytest slow markers based on test execution time - Add /tune-slow-markers slash command for PR automation - Tool can add slow markers to tests exceeding timeout threshold - Tool can optionally remove slow markers from fast tests - Configurable timeout threshold (default: 7.0s) - Supports dry-run mode for preview - Compatible with pipx run for standalone usage Co-Authored-By: AJ Steers --- .github/workflows/slash_command_dispatch.yml | 1 + .../workflows/tune-slow-markers-command.yml | 82 ++++ bin/__init__.py | 1 + bin/tune_slow_markers.py | 357 ++++++++++++++++++ pyproject.toml | 4 +- 5 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tune-slow-markers-command.yml create mode 100644 bin/__init__.py create mode 100755 bin/tune_slow_markers.py diff --git a/.github/workflows/slash_command_dispatch.yml b/.github/workflows/slash_command_dispatch.yml index c45a34ebc..8df02e55e 100644 --- a/.github/workflows/slash_command_dispatch.yml +++ b/.github/workflows/slash_command_dispatch.yml @@ -34,6 +34,7 @@ jobs: fix-pr test-pr poetry-lock + tune-slow-markers static-args: | pr=${{ github.event.issue.number }} comment-id=${{ github.event.comment.id }} diff --git a/.github/workflows/tune-slow-markers-command.yml b/.github/workflows/tune-slow-markers-command.yml new file mode 100644 index 000000000..98eb68686 --- /dev/null +++ b/.github/workflows/tune-slow-markers-command.yml @@ -0,0 +1,82 @@ +name: Tune Slow Markers Command + +on: + repository_dispatch: + types: [tune-slow-markers-command] + +env: + AIRBYTE_ANALYTICS_ID: ${{ vars.AIRBYTE_ANALYTICS_ID }} + +jobs: + tune-slow-markers: + name: Tune Slow Markers + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} + ref: ${{ github.event.client_payload.pull_request.head.ref }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.10' + + - name: Set up Poetry + uses: Gr1N/setup-poetry@48b0f77c8c1b1b19cb962f0f00dff7b4be8f81ec # v9 + with: + poetry-version: "2.2.0" + + - name: Install dependencies + run: poetry install + + - name: Run slow marker tuner + run: | + poetry run python bin/tune_slow_markers.py --timeout 7.0 --remove-slow + + - name: Check for changes + id: check_changes + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "changes=true" >> $GITHUB_OUTPUT + else + echo "changes=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: steps.check_changes.outputs.changes == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add tests/ + git commit -m "chore: Auto-tune pytest slow markers" + git push + + - name: Add reaction to comment + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.event.client_payload.github.payload.repository.full_name }} + comment-id: ${{ github.event.client_payload.github.payload.comment.id }} + reactions: rocket + + - name: Comment on PR with results + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.client_payload.github.payload.issue.number }} + body: | + ✅ Slow marker tuning complete! + + ${{ steps.check_changes.outputs.changes == 'true' && 'Changes have been committed and pushed to this PR.' || 'No changes were needed - all markers are already correctly set.' }} + + - name: Comment on PR with failure + if: failure() + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.client_payload.github.payload.issue.number }} + body: | + ❌ Slow marker tuning failed. Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. diff --git a/bin/__init__.py b/bin/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/bin/__init__.py @@ -0,0 +1 @@ + diff --git a/bin/tune_slow_markers.py b/bin/tune_slow_markers.py new file mode 100755 index 000000000..cc5d6b818 --- /dev/null +++ b/bin/tune_slow_markers.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Airbyte, Inc., all rights reserved. +"""Automated pytest slow marker tuner for PyAirbyte. + +This tool runs all tests with a timeout throttle and automatically adds or removes +the @pytest.mark.slow decorator based on test execution time. + +Usage: + python bin/tune_slow_markers.py [--timeout SECONDS] [--dry-run] [--remove-slow] + pipx run airbyte tune-slow-markers [--timeout SECONDS] [--dry-run] [--remove-slow] +""" + +import argparse +import ast +import re +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Automatically tune pytest slow markers based on test execution time" + ) + parser.add_argument( + "--timeout", + type=float, + default=7.0, + help="Timeout threshold in seconds (default: 7.0)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without modifying files", + ) + parser.add_argument( + "--remove-slow", + action="store_true", + help="Also remove slow markers from tests that finish within the threshold", + ) + parser.add_argument( + "--test-path", + type=str, + default="tests/", + help="Path to test directory (default: tests/)", + ) + return parser.parse_args() + + +def run_pytest_with_durations(test_path: str) -> tuple[list[dict[str, any]], int]: + """Run pytest with durations reporting to get timing information for all tests. + + Returns: + Tuple of (list of test results with timing, return code) + """ + cmd = [ + "poetry", + "run", + "pytest", + test_path, + "--durations=0", + "--tb=no", + "-v", + "--collect-only", + "-q", + ] + + print(f"Collecting tests from {test_path}...") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + if result.returncode not in (0, 5): + print(f"Warning: Test collection returned code {result.returncode}") + + cmd = [ + "poetry", + "run", + "pytest", + test_path, + "--durations=0", + "--tb=line", + "-v", + "-m", + "not super_slow", + ] + + print(f"Running tests from {test_path} to collect timing data...") + print("This may take a while...") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + test_timings = [] + duration_pattern = re.compile(r"^([\d.]+)s\s+(?:call|setup|teardown)\s+(.+)$") + + for line in result.stdout.split("\n"): + match = duration_pattern.match(line.strip()) + if match: + duration = float(match.group(1)) + test_name = match.group(2) + test_timings.append({"test": test_name, "duration": duration}) + + return test_timings, result.returncode + + +def parse_test_name(test_name: str) -> tuple[str, str]: + """Parse pytest test name to extract file path and test function name. + + Example: 'tests/unit_tests/test_foo.py::test_bar' -> ('tests/unit_tests/test_foo.py', 'test_bar') + """ + if "::" in test_name: + parts = test_name.split("::") + file_path = parts[0] + test_func = parts[-1] + if "[" in test_func: + test_func = test_func.split("[")[0] + return file_path, test_func + return "", "" + + +def find_slow_tests(test_timings: list[dict[str, any]], threshold: float) -> set[tuple[str, str]]: + """Identify tests that exceed the timeout threshold. + + Returns: + Set of (file_path, test_function_name) tuples + """ + slow_tests = set() + + for timing in test_timings: + if timing["duration"] > threshold: + file_path, test_func = parse_test_name(timing["test"]) + if file_path and test_func: + slow_tests.add((file_path, test_func)) + print(f" Slow test found: {test_func} in {file_path} ({timing['duration']:.2f}s)") + + return slow_tests + + +def find_fast_tests(test_timings: list[dict[str, any]], threshold: float) -> set[tuple[str, str]]: + """Identify tests that finish within the timeout threshold. + + Returns: + Set of (file_path, test_function_name) tuples + """ + fast_tests = set() + + for timing in test_timings: + if timing["duration"] <= threshold: + file_path, test_func = parse_test_name(timing["test"]) + if file_path and test_func: + fast_tests.add((file_path, test_func)) + + return fast_tests + + +def has_slow_marker(test_node: ast.FunctionDef) -> bool: + """Check if a test function has @pytest.mark.slow decorator.""" + for decorator in test_node.decorator_list: + if isinstance(decorator, ast.Attribute): + if ( + isinstance(decorator.value, ast.Attribute) + and isinstance(decorator.value.value, ast.Name) + and decorator.value.value.id == "pytest" + and decorator.value.attr == "mark" + and decorator.attr == "slow" + ): + return True + elif isinstance(decorator, ast.Call): + if isinstance(decorator.func, ast.Attribute): + if ( + isinstance(decorator.func.value, ast.Attribute) + and isinstance(decorator.func.value.value, ast.Name) + and decorator.func.value.value.id == "pytest" + and decorator.func.value.attr == "mark" + and decorator.func.attr == "slow" + ): + return True + return False + + +def add_slow_marker_to_file(file_path: str, test_functions: set[str], dry_run: bool = False) -> int: + """Add @pytest.mark.slow decorator to specified test functions in a file. + + Returns: + Number of markers added + """ + path = Path(file_path) + if not path.exists(): + print(f"Warning: File not found: {file_path}") + return 0 + + content = path.read_text() + lines = content.split("\n") + + try: + tree = ast.parse(content) + except SyntaxError as e: + print(f"Warning: Could not parse {file_path}: {e}") + return 0 + + markers_added = 0 + modifications = [] + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in test_functions: + if not has_slow_marker(node): + lineno = node.lineno - 1 + indent = len(lines[lineno]) - len(lines[lineno].lstrip()) + marker_line = " " * indent + "@pytest.mark.slow" + + modifications.append((lineno, marker_line)) + markers_added += 1 + + if dry_run: + print( + f" [DRY RUN] Would add @pytest.mark.slow to {node.name} at line {lineno + 1}" + ) + else: + print(f" Adding @pytest.mark.slow to {node.name} at line {lineno + 1}") + + if not dry_run and modifications: + for lineno, marker_line in sorted(modifications, reverse=True): + lines.insert(lineno, marker_line) + + path.write_text("\n".join(lines)) + + return markers_added + + +def remove_slow_marker_from_file( + file_path: str, test_functions: set[str], dry_run: bool = False +) -> int: + """Remove @pytest.mark.slow decorator from specified test functions in a file. + + Returns: + Number of markers removed + """ + path = Path(file_path) + if not path.exists(): + print(f"Warning: File not found: {file_path}") + return 0 + + content = path.read_text() + lines = content.split("\n") + + try: + tree = ast.parse(content) + except SyntaxError as e: + print(f"Warning: Could not parse {file_path}: {e}") + return 0 + + markers_removed = 0 + lines_to_remove = [] + + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in test_functions: + if has_slow_marker(node): + for i in range(max(0, node.lineno - 10), node.lineno): + line = lines[i].strip() + if line == "@pytest.mark.slow": + lines_to_remove.append(i) + markers_removed += 1 + + if dry_run: + print( + f" [DRY RUN] Would remove @pytest.mark.slow from {node.name} at line {i + 1}" + ) + else: + print(f" Removing @pytest.mark.slow from {node.name} at line {i + 1}") + break + + if not dry_run and lines_to_remove: + for lineno in sorted(lines_to_remove, reverse=True): + del lines[lineno] + + path.write_text("\n".join(lines)) + + return markers_removed + + +def main() -> int: + """Main entry point for the slow marker tuner.""" + args = parse_args() + + print("=" * 80) + print("PyAirbyte Slow Marker Tuner") + print("=" * 80) + print(f"Timeout threshold: {args.timeout}s") + print(f"Test path: {args.test_path}") + print(f"Dry run: {args.dry_run}") + print(f"Remove slow markers: {args.remove_slow}") + print("=" * 80) + print() + + test_timings, _ = run_pytest_with_durations(args.test_path) + + if not test_timings: + print("No test timing data collected. Tests may have failed to run.") + return 1 + + print(f"\nCollected timing data for {len(test_timings)} test executions") + print() + + print(f"Finding tests that exceed {args.timeout}s threshold...") + slow_tests = find_slow_tests(test_timings, args.timeout) + + print(f"\nFound {len(slow_tests)} slow tests") + print() + + tests_by_file: dict[str, set[str]] = {} + for file_path, test_func in slow_tests: + if file_path not in tests_by_file: + tests_by_file[file_path] = set() + tests_by_file[file_path].add(test_func) + + total_added = 0 + for file_path, test_funcs in tests_by_file.items(): + print(f"Processing {file_path}...") + added = add_slow_marker_to_file(file_path, test_funcs, args.dry_run) + total_added += added + + print() + print(f"Total slow markers added: {total_added}") + + if args.remove_slow: + print() + print(f"Finding tests that finish within {args.timeout}s threshold...") + fast_tests = find_fast_tests(test_timings, args.timeout) + + print(f"\nFound {len(fast_tests)} fast tests") + print() + + fast_tests_by_file: dict[str, set[str]] = {} + for file_path, test_func in fast_tests: + if file_path not in fast_tests_by_file: + fast_tests_by_file[file_path] = set() + fast_tests_by_file[file_path].add(test_func) + + total_removed = 0 + for file_path, test_funcs in fast_tests_by_file.items(): + print(f"Processing {file_path}...") + removed = remove_slow_marker_from_file(file_path, test_funcs, args.dry_run) + total_removed += removed + + print() + print(f"Total slow markers removed: {total_removed}") + + print() + print("=" * 80) + if args.dry_run: + print("DRY RUN COMPLETE - No files were modified") + else: + print("COMPLETE") + print("=" * 80) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 6518715cc..1cdb9cd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "airbyte" description = "PyAirbyte" authors = ["Airbyte "] readme = "README.md" -packages = [{ include = "airbyte" }] +packages = [{ include = "airbyte" }, { include = "bin" }] # This project uses dynamic versioning # https://github.com/mtkennerly/poetry-dynamic-versioning @@ -132,11 +132,13 @@ venv = ".venv" pyairbyte = "airbyte.cli:cli" pyab = "airbyte.cli:cli" airbyte-mcp = "airbyte.mcp.server:main" +tune-slow-markers = "bin.tune_slow_markers:main" [tool.poe.tasks] test = { shell = "pytest" } test-fast = { shell = "pytest --durations=5 --exitfirst -m 'not slow'" } test-unit-tests = { shell = "pytest tests/unit_tests/" } +tune-slow-markers = { cmd = "python bin/tune_slow_markers.py", help = "Automatically tune pytest slow markers based on test execution time" } coverage = { shell = "coverage run -m pytest && coverage report" } coverage-report = { shell = "coverage report" } From 6dd20790b1a6b9eb2fc6f9981163c4865a4e1210 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 04:18:21 +0000 Subject: [PATCH 2/2] refactor: Update slow marker tuner to use PEP 723 and uv - Convert script to use PEP 723 inline script metadata - Add shebang for direct execution with uv run - Add get_pytest_command() to auto-detect pytest command - Update GitHub workflow to use uv instead of poetry - Remove bin package from pyproject.toml (no longer needed) - Update poe task to use uv run - Delete bin/__init__.py (no longer needed) Co-Authored-By: AJ Steers --- .../workflows/tune-slow-markers-command.yml | 15 +- bin/__init__.py | 1 - bin/tune_slow_markers.py | 175 +++++++++++------- pyproject.toml | 5 +- 4 files changed, 119 insertions(+), 77 deletions(-) delete mode 100644 bin/__init__.py diff --git a/.github/workflows/tune-slow-markers-command.yml b/.github/workflows/tune-slow-markers-command.yml index 98eb68686..30f2e4884 100644 --- a/.github/workflows/tune-slow-markers-command.yml +++ b/.github/workflows/tune-slow-markers-command.yml @@ -19,22 +19,19 @@ jobs: repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} ref: ${{ github.event.client_payload.pull_request.head.ref }} + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.10' - - name: Set up Poetry - uses: Gr1N/setup-poetry@48b0f77c8c1b1b19cb962f0f00dff7b4be8f81ec # v9 - with: - poetry-version: "2.2.0" - - - name: Install dependencies - run: poetry install - - name: Run slow marker tuner run: | - poetry run python bin/tune_slow_markers.py --timeout 7.0 --remove-slow + uv run bin/tune_slow_markers.py --timeout 7.0 --remove-slow - name: Check for changes id: check_changes diff --git a/bin/__init__.py b/bin/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/bin/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/bin/tune_slow_markers.py b/bin/tune_slow_markers.py index cc5d6b818..ae1d67b99 100755 --- a/bin/tune_slow_markers.py +++ b/bin/tune_slow_markers.py @@ -1,4 +1,10 @@ -#!/usr/bin/env python3 +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pytest>=8.2.0", +# ] +# /// # Copyright (c) 2025 Airbyte, Inc., all rights reserved. """Automated pytest slow marker tuner for PyAirbyte. @@ -6,19 +12,38 @@ the @pytest.mark.slow decorator based on test execution time. Usage: - python bin/tune_slow_markers.py [--timeout SECONDS] [--dry-run] [--remove-slow] - pipx run airbyte tune-slow-markers [--timeout SECONDS] [--dry-run] [--remove-slow] + uv run bin/tune_slow_markers.py [--timeout SECONDS] [--dry-run] [--remove-slow] + ./bin/tune_slow_markers.py [--timeout SECONDS] [--dry-run] [--remove-slow] """ import argparse import ast import re +import shutil import subprocess import sys from pathlib import Path +def get_pytest_command() -> list[str]: + """Determine the appropriate pytest command to use. + + Returns: + List of command parts to run pytest + """ + if Path("pyproject.toml").exists() and shutil.which("poetry"): + return ["poetry", "run", "pytest"] + elif shutil.which("pytest"): + return ["pytest"] + else: + raise RuntimeError( + "pytest not found. Please ensure pytest is installed " + "or run from a poetry project." + ) + + def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" parser = argparse.ArgumentParser( description="Automatically tune pytest slow markers based on test execution time" ) @@ -53,10 +78,10 @@ def run_pytest_with_durations(test_path: str) -> tuple[list[dict[str, any]], int Returns: Tuple of (list of test results with timing, return code) """ + pytest_cmd = get_pytest_command() + cmd = [ - "poetry", - "run", - "pytest", + *pytest_cmd, test_path, "--durations=0", "--tb=no", @@ -68,13 +93,11 @@ def run_pytest_with_durations(test_path: str) -> tuple[list[dict[str, any]], int print(f"Collecting tests from {test_path}...") result = subprocess.run(cmd, capture_output=True, text=True, check=False) - if result.returncode not in (0, 5): + if result.returncode not in {0, 5}: print(f"Warning: Test collection returned code {result.returncode}") cmd = [ - "poetry", - "run", - "pytest", + *pytest_cmd, test_path, "--durations=0", "--tb=line", @@ -103,7 +126,9 @@ def run_pytest_with_durations(test_path: str) -> tuple[list[dict[str, any]], int def parse_test_name(test_name: str) -> tuple[str, str]: """Parse pytest test name to extract file path and test function name. - Example: 'tests/unit_tests/test_foo.py::test_bar' -> ('tests/unit_tests/test_foo.py', 'test_bar') + Example: + 'tests/unit_tests/test_foo.py::test_bar' -> + ('tests/unit_tests/test_foo.py', 'test_bar') """ if "::" in test_name: parts = test_name.split("::") @@ -115,7 +140,9 @@ def parse_test_name(test_name: str) -> tuple[str, str]: return "", "" -def find_slow_tests(test_timings: list[dict[str, any]], threshold: float) -> set[tuple[str, str]]: +def find_slow_tests( + test_timings: list[dict[str, any]], threshold: float +) -> set[tuple[str, str]]: """Identify tests that exceed the timeout threshold. Returns: @@ -128,12 +155,17 @@ def find_slow_tests(test_timings: list[dict[str, any]], threshold: float) -> set file_path, test_func = parse_test_name(timing["test"]) if file_path and test_func: slow_tests.add((file_path, test_func)) - print(f" Slow test found: {test_func} in {file_path} ({timing['duration']:.2f}s)") + print( + f" Slow test found: {test_func} in {file_path} " + f"({timing['duration']:.2f}s)" + ) return slow_tests -def find_fast_tests(test_timings: list[dict[str, any]], threshold: float) -> set[tuple[str, str]]: +def find_fast_tests( + test_timings: list[dict[str, any]], threshold: float +) -> set[tuple[str, str]]: """Identify tests that finish within the timeout threshold. Returns: @@ -153,29 +185,31 @@ def find_fast_tests(test_timings: list[dict[str, any]], threshold: float) -> set def has_slow_marker(test_node: ast.FunctionDef) -> bool: """Check if a test function has @pytest.mark.slow decorator.""" for decorator in test_node.decorator_list: - if isinstance(decorator, ast.Attribute): + if isinstance(decorator, ast.Attribute) and ( + isinstance(decorator.value, ast.Attribute) + and isinstance(decorator.value.value, ast.Name) + and decorator.value.value.id == "pytest" + and decorator.value.attr == "mark" + and decorator.attr == "slow" + ): + return True + elif isinstance(decorator, ast.Call) and isinstance( + decorator.func, ast.Attribute + ): if ( - isinstance(decorator.value, ast.Attribute) - and isinstance(decorator.value.value, ast.Name) - and decorator.value.value.id == "pytest" - and decorator.value.attr == "mark" - and decorator.attr == "slow" + isinstance(decorator.func.value, ast.Attribute) + and isinstance(decorator.func.value.value, ast.Name) + and decorator.func.value.value.id == "pytest" + and decorator.func.value.attr == "mark" + and decorator.func.attr == "slow" ): return True - elif isinstance(decorator, ast.Call): - if isinstance(decorator.func, ast.Attribute): - if ( - isinstance(decorator.func.value, ast.Attribute) - and isinstance(decorator.func.value.value, ast.Name) - and decorator.func.value.value.id == "pytest" - and decorator.func.value.attr == "mark" - and decorator.func.attr == "slow" - ): - return True return False -def add_slow_marker_to_file(file_path: str, test_functions: set[str], dry_run: bool = False) -> int: +def add_slow_marker_to_file( + file_path: str, test_functions: set[str], *, dry_run: bool = False +) -> int: """Add @pytest.mark.slow decorator to specified test functions in a file. Returns: @@ -199,21 +233,25 @@ def add_slow_marker_to_file(file_path: str, test_functions: set[str], dry_run: b modifications = [] for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in test_functions: - if not has_slow_marker(node): - lineno = node.lineno - 1 - indent = len(lines[lineno]) - len(lines[lineno].lstrip()) - marker_line = " " * indent + "@pytest.mark.slow" - - modifications.append((lineno, marker_line)) - markers_added += 1 - - if dry_run: - print( - f" [DRY RUN] Would add @pytest.mark.slow to {node.name} at line {lineno + 1}" - ) - else: - print(f" Adding @pytest.mark.slow to {node.name} at line {lineno + 1}") + if ( + isinstance(node, ast.FunctionDef) + and node.name in test_functions + and not has_slow_marker(node) + ): + lineno = node.lineno - 1 + indent = len(lines[lineno]) - len(lines[lineno].lstrip()) + marker_line = " " * indent + "@pytest.mark.slow" + + modifications.append((lineno, marker_line)) + markers_added += 1 + + if dry_run: + print( + f" [DRY RUN] Would add @pytest.mark.slow to " + f"{node.name} at line {lineno + 1}" + ) + else: + print(f" Adding @pytest.mark.slow to {node.name} at line {lineno + 1}") if not dry_run and modifications: for lineno, marker_line in sorted(modifications, reverse=True): @@ -225,7 +263,7 @@ def add_slow_marker_to_file(file_path: str, test_functions: set[str], dry_run: b def remove_slow_marker_from_file( - file_path: str, test_functions: set[str], dry_run: bool = False + file_path: str, test_functions: set[str], *, dry_run: bool = False ) -> int: """Remove @pytest.mark.slow decorator from specified test functions in a file. @@ -250,21 +288,28 @@ def remove_slow_marker_from_file( lines_to_remove = [] for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in test_functions: - if has_slow_marker(node): - for i in range(max(0, node.lineno - 10), node.lineno): - line = lines[i].strip() - if line == "@pytest.mark.slow": - lines_to_remove.append(i) - markers_removed += 1 - - if dry_run: - print( - f" [DRY RUN] Would remove @pytest.mark.slow from {node.name} at line {i + 1}" - ) - else: - print(f" Removing @pytest.mark.slow from {node.name} at line {i + 1}") - break + if ( + isinstance(node, ast.FunctionDef) + and node.name in test_functions + and has_slow_marker(node) + ): + for i in range(max(0, node.lineno - 10), node.lineno): + line = lines[i].strip() + if line == "@pytest.mark.slow": + lines_to_remove.append(i) + markers_removed += 1 + + if dry_run: + print( + f" [DRY RUN] Would remove @pytest.mark.slow from " + f"{node.name} at line {i + 1}" + ) + else: + print( + f" Removing @pytest.mark.slow from " + f"{node.name} at line {i + 1}" + ) + break if not dry_run and lines_to_remove: for lineno in sorted(lines_to_remove, reverse=True): @@ -313,7 +358,7 @@ def main() -> int: total_added = 0 for file_path, test_funcs in tests_by_file.items(): print(f"Processing {file_path}...") - added = add_slow_marker_to_file(file_path, test_funcs, args.dry_run) + added = add_slow_marker_to_file(file_path, test_funcs, dry_run=args.dry_run) total_added += added print() @@ -336,7 +381,9 @@ def main() -> int: total_removed = 0 for file_path, test_funcs in fast_tests_by_file.items(): print(f"Processing {file_path}...") - removed = remove_slow_marker_from_file(file_path, test_funcs, args.dry_run) + removed = remove_slow_marker_from_file( + file_path, test_funcs, dry_run=args.dry_run + ) total_removed += removed print() diff --git a/pyproject.toml b/pyproject.toml index 1cdb9cd0d..63c030568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "airbyte" description = "PyAirbyte" authors = ["Airbyte "] readme = "README.md" -packages = [{ include = "airbyte" }, { include = "bin" }] +packages = [{ include = "airbyte" }] # This project uses dynamic versioning # https://github.com/mtkennerly/poetry-dynamic-versioning @@ -132,13 +132,12 @@ venv = ".venv" pyairbyte = "airbyte.cli:cli" pyab = "airbyte.cli:cli" airbyte-mcp = "airbyte.mcp.server:main" -tune-slow-markers = "bin.tune_slow_markers:main" [tool.poe.tasks] test = { shell = "pytest" } test-fast = { shell = "pytest --durations=5 --exitfirst -m 'not slow'" } test-unit-tests = { shell = "pytest tests/unit_tests/" } -tune-slow-markers = { cmd = "python bin/tune_slow_markers.py", help = "Automatically tune pytest slow markers based on test execution time" } +tune-slow-markers = { cmd = "uv run bin/tune_slow_markers.py", help = "Automatically tune pytest slow markers based on test execution time" } coverage = { shell = "coverage run -m pytest && coverage report" } coverage-report = { shell = "coverage report" }