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..f03d3ea65 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,66 @@ +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'), + ('test/devscripts', 'devscripts') + ] + + 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 8fcd0de64..13149c3fc 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,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..54f7e7104 --- /dev/null +++ b/devscripts/run_coverage.py @@ -0,0 +1,105 @@ +#!/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,devscripts' + elif len(args) == 1: + test_path = args[0] + # Try to guess the module path from the test path + if test_path.startswith('test/devscripts'): + module_path = 'devscripts' + elif test_path.startswith('test/'): + module_path = 'yt_dlp' + else: + module_path = 'yt_dlp,devscripts' + 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: open {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 2a0008a45..33be8ed95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ static-analysis = [ test = [ "pytest~=8.1", "pytest-rerunfailures~=14.0", + "pytest-cov~=6.0", ] pyinstaller = [ "pyinstaller>=6.11.1", # Windows temp cleanup fixed in 6.11.1 @@ -161,11 +162,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 diff --git a/test/devscripts/__init__.py b/test/devscripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/devscripts/test_install_deps.py b/test/devscripts/test_install_deps.py new file mode 100644 index 000000000..8aeeb3a89 --- /dev/null +++ b/test/devscripts/test_install_deps.py @@ -0,0 +1,62 @@ +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from devscripts import install_deps + + +class TestInstallDeps(unittest.TestCase): + + @mock.patch('devscripts.install_deps.parse_toml') + @mock.patch('devscripts.install_deps.read_file') + @mock.patch('devscripts.install_deps.subprocess.call') + def test_print_option(self, mock_call, mock_read_file, mock_parse_toml): + # Mock the parse_toml function to return a project table with dependencies + mock_parse_toml.return_value = { + 'project': { + 'name': 'yt-dlp', + 'dependencies': ['dep1', 'dep2'], + 'optional-dependencies': { + 'default': ['opt1', 'opt2'], + 'test': ['test1', 'test2'], + 'dev': ['dev1', 'dev2'], + }, + }, + } + + # Mock sys.argv to simulate command line arguments + with mock.patch('sys.argv', ['install_deps.py', '--print']): + # Redirect stdout to capture the output + from io import StringIO + import sys + original_stdout = sys.stdout + try: + output = StringIO() + sys.stdout = output + + # Execute the main function + install_deps.main() + + # Get the captured output + printed_deps = output.getvalue().strip().split('\n') + + # Check that default dependencies are included + # 2 from dependencies + default dependencies + self.assertEqual(len(printed_deps), 4) + self.assertIn('dep1', printed_deps) + self.assertIn('dep2', printed_deps) + self.assertIn('opt1', printed_deps) + self.assertIn('opt2', printed_deps) + + finally: + sys.stdout = original_stdout + + # Call was not made because we used --print + mock_call.assert_not_called() + + +if __name__ == '__main__': + unittest.main()