diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..a051fd819 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,28 @@ +[coverage:run] +source = yt_dlp, devscripts +omit = + */extractor/lazy_extractors.py + */__pycache__/* + */test/* + */yt_dlp/compat/_deprecated.py + */yt_dlp/compat/_legacy.py +data_file = .coverage + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + raise NotImplementedError + if __name__ == .__main__.: + pass + raise ImportError + except ImportError: + warnings\.warn + if TYPE_CHECKING: + +[coverage:html] +directory = .coverage-reports/html +title = yt-dlp Coverage Report + +[coverage:xml] +output = .coverage-reports/coverage.xml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..cdb45e09e --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,65 @@ +name: Code Coverage + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + pip install pytest-cov + + - name: Run coverage tests in parallel + run: | + # Create a simple script to run coverage tests in parallel + cat > run_parallel_coverage.py << 'EOF' + import concurrent.futures + import subprocess + import sys + + def run_coverage(args): + test_path, module_path = args + cmd = ['python', '-m', 'devscripts.run_coverage', test_path, module_path] + return subprocess.run(cmd, check=True) + + coverage_tests = [ + ('test/test_utils.py', 'yt_dlp.utils'), + ('test/test_YoutubeDL.py', 'yt_dlp.YoutubeDL') + ] + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(run_coverage, test) for test in coverage_tests] + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except subprocess.CalledProcessError as e: + print(f"Error running coverage: {e}") + sys.exit(1) + EOF + + # Run the script + python run_parallel_coverage.py + + - name: Archive coverage results + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + .coverage-reports/html/ + .coverage-reports/coverage.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 40bb34d2a..2d83ad513 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ yt-dlp.zip # Plugins ytdlp_plugins/ yt-dlp-plugins + +# Coverage +/.coverage-reports/ diff --git a/devscripts/cov-combine b/devscripts/cov-combine new file mode 100755 index 000000000..eb8fe11cb --- /dev/null +++ b/devscripts/cov-combine @@ -0,0 +1,5 @@ +#!/bin/sh +# This script is a helper for the Hatch test coverage command +# It's called by `hatch test --cover` + +coverage combine "$@" \ No newline at end of file diff --git a/devscripts/run_coverage.py b/devscripts/run_coverage.py new file mode 100755 index 000000000..86abd59eb --- /dev/null +++ b/devscripts/run_coverage.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 + +# Script to run coverage tests for yt-dlp +# +# Usage: +# python -m devscripts.run_coverage [test_path] [module_path] [additional pytest args] +# +# Examples: +# python -m devscripts.run_coverage # Test everything +# python -m devscripts.run_coverage test/devscripts # Test devscripts +# python -m devscripts.run_coverage test/test_utils.py yt_dlp.utils # Test specific module +# python -m devscripts.run_coverage test/test_utils.py "yt_dlp.utils,yt_dlp.YoutubeDL" # Test multiple modules +# python -m devscripts.run_coverage test -v # With verbosity +# +# Using hatch: +# hatch run hatch-test:run-cov [args] # Same arguments as above +# hatch test --cover # Run all tests with coverage +# +# Important: +# - Always run this script from the project root directory +# - Test paths are relative to the project root +# - Module paths use Python import syntax (with dots) +# - Coverage reports are generated in .coverage-reports/ + +import sys +import subprocess +from pathlib import Path + +script_dir = Path(__file__).parent +repo_root = script_dir.parent + + +def main(): + args = sys.argv[1:] + + if not args: + # Default to running all tests + test_path = 'test' + module_path = 'yt_dlp' + elif len(args) == 1: + test_path = args[0] + # Try to guess the module path from the test path + if test_path.startswith('test/'): + module_path = 'yt_dlp' + else: + module_path = 'yt_dlp' + else: + test_path = args[0] + module_path = args[1] + + # Initialize coverage reports directory + cov_dir = repo_root / '.coverage-reports' + cov_dir.mkdir(exist_ok=True) + html_dir = cov_dir / 'html' + html_dir.mkdir(exist_ok=True) + + # Run pytest with coverage + cmd = [ + 'python', '-m', 'pytest', + f'--cov={module_path}', + '--cov-config=.coveragerc', + '--cov-report=term-missing', + test_path, + ] + + if len(args) > 2: + cmd.extend(args[2:]) + + print(f'Running coverage on {test_path} for module(s) {module_path}') + print(f'Command: {" ".join(cmd)}') + + try: + result = subprocess.run(cmd, check=True) + + # Generate reports after the test run in parallel + import concurrent.futures + + def generate_html_report(): + return subprocess.run([ + 'python', '-m', 'coverage', 'html', + ], check=True) + + def generate_xml_report(): + return subprocess.run([ + 'python', '-m', 'coverage', 'xml', + ], check=True) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + html_future = executor.submit(generate_html_report) + xml_future = executor.submit(generate_xml_report) + # Wait for both tasks to complete + concurrent.futures.wait([html_future, xml_future]) + + print(f'\nCoverage reports saved to {cov_dir.as_posix()}') + print(f'HTML report: {cov_dir.as_posix()}/html/index.html') + return result.returncode + except subprocess.CalledProcessError as e: + print(f'Error running coverage: {e}') + return e.returncode + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 3775251e1..7fdb265a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ static-analysis = [ test = [ "pytest~=8.1", "pytest-rerunfailures~=14.0", + "pytest-cov~=6.0", ] pyinstaller = [ "pyinstaller>=6.13.0", # Windows temp cleanup fixed in 6.13.0 @@ -160,11 +161,12 @@ features = ["test"] dependencies = [ "pytest-randomly~=3.15", "pytest-xdist[psutil]~=3.5", + "pytest-cov~=6.0", ] [tool.hatch.envs.hatch-test.scripts] run = "python -m devscripts.run_tests {args}" -run-cov = "echo Code coverage not implemented && exit 1" +run-cov = "python -m devscripts.run_coverage {args}" [[tool.hatch.envs.hatch-test.matrix]] python = [ diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..26942a515 --- /dev/null +++ b/test/README.md @@ -0,0 +1,39 @@ +# yt-dlp Tests + +This directory contains tests for the yt-dlp codebase. + +## Running Tests + +### Using hatch (requires `pip install hatch`) + +```bash +# Run tests for a specific test file +hatch run hatch-test:run test/test_utils.py + +# Run a specific test class or method +hatch run hatch-test:run test/test_utils.py::TestUtil +hatch run hatch-test:run test/test_utils.py::TestUtil::test_url_basename + +# Run with verbosity +hatch run hatch-test:run -- test/test_utils.py -v +``` + +### Using pytest directly + +```bash +# Run a specific test file +python -m pytest test/test_utils.py + +# Run a specific test class or method +python -m pytest test/test_utils.py::TestUtil +python -m pytest test/test_utils.py::TestUtil::test_url_basename + +# Run with verbosity +python -m pytest -v test/test_utils.py +``` + +**Important:** Always run tests from the project root directory, not from a subdirectory. + +## Code Coverage + +For information on running tests with code coverage, see the documentation in `.coverage-reports/README.md`. \ No newline at end of file