codeowners-coverage 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """CODEOWNERS coverage checking tool."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .checker import CoverageChecker
6
+ from .config import Config
7
+ from .matcher import CodeOwnersPatternMatcher
8
+
9
+ __all__ = ["CoverageChecker", "Config", "CodeOwnersPatternMatcher"]
@@ -0,0 +1,12 @@
1
+ """Entry point for codeowners-coverage CLI."""
2
+
3
+ from .cli import cli
4
+
5
+
6
+ def main() -> None:
7
+ """Main entry point."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,157 @@
1
+ """Core coverage checking logic for CODEOWNERS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Dict, List, Set
8
+
9
+ import pathspec
10
+
11
+ from .config import Config
12
+ from .matcher import CodeOwnersPatternMatcher
13
+
14
+
15
+ class CoverageChecker:
16
+ """Check CODEOWNERS coverage for repository files."""
17
+
18
+ def __init__(self, config: Config) -> None:
19
+ """
20
+ Initialize coverage checker.
21
+
22
+ Args:
23
+ config: Configuration object
24
+ """
25
+ self.config = config
26
+ self.matcher = CodeOwnersPatternMatcher(config.codeowners_path)
27
+ self.exclusions = pathspec.PathSpec.from_lines("gitignore", config.exclusions)
28
+
29
+ def get_repository_files(self) -> List[str]:
30
+ """
31
+ Get all files in repository using git ls-files.
32
+
33
+ Returns:
34
+ List of file paths relative to repository root
35
+ """
36
+ result = subprocess.run(
37
+ ["git", "ls-files"],
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+ files = result.stdout.strip().split("\n")
43
+ # Filter out empty strings
44
+ return [f for f in files if f]
45
+
46
+ def _load_baseline(self) -> Set[str]:
47
+ """
48
+ Load baseline file of allowed uncovered files.
49
+
50
+ Returns:
51
+ Set of file paths that are allowed to be uncovered
52
+ """
53
+ baseline_path = Path(self.config.baseline_path)
54
+
55
+ if not baseline_path.exists():
56
+ return set()
57
+
58
+ baseline_files = set()
59
+ with open(baseline_path) as f:
60
+ for line in f:
61
+ line = line.strip()
62
+ # Skip comments and empty lines
63
+ if line and not line.startswith("#"):
64
+ baseline_files.add(line)
65
+
66
+ return baseline_files
67
+
68
+ def check_coverage(self, files: List[str] | None = None) -> Dict[str, object]:
69
+ """
70
+ Check which files lack CODEOWNERS coverage.
71
+
72
+ Args:
73
+ files: Optional list of specific files to check.
74
+ If None, checks all repository files.
75
+
76
+ Returns:
77
+ Dictionary with coverage statistics:
78
+ {
79
+ "total_files": int,
80
+ "covered_files": int,
81
+ "uncovered_files": [list of uncovered file paths],
82
+ "baseline_files": [list of baseline file paths],
83
+ "coverage_percentage": float
84
+ }
85
+ """
86
+ if files is None:
87
+ files = self.get_repository_files()
88
+
89
+ # Filter excluded files
90
+ filtered_files = [f for f in files if not self.exclusions.match_file(f)]
91
+
92
+ # Check coverage
93
+ uncovered = [f for f in filtered_files if not self.matcher.matches(f)]
94
+
95
+ # Load baseline
96
+ baseline = self._load_baseline()
97
+
98
+ # Separate new uncovered files from baseline files
99
+ new_uncovered = [f for f in uncovered if f not in baseline]
100
+ baseline_files = [f for f in uncovered if f in baseline]
101
+
102
+ # Calculate coverage
103
+ total_files = len(filtered_files)
104
+ covered_files = total_files - len(uncovered)
105
+ coverage_percentage = (covered_files / total_files * 100) if total_files > 0 else 100.0
106
+
107
+ return {
108
+ "total_files": total_files,
109
+ "covered_files": covered_files,
110
+ "uncovered_files": new_uncovered,
111
+ "baseline_files": baseline_files,
112
+ "coverage_percentage": coverage_percentage,
113
+ }
114
+
115
+ def generate_baseline(self, files: List[str] | None = None) -> List[str]:
116
+ """
117
+ Generate a baseline of all currently uncovered files.
118
+
119
+ Args:
120
+ files: Optional list of specific files to check.
121
+ If None, checks all repository files.
122
+
123
+ Returns:
124
+ List of uncovered file paths (sorted)
125
+ """
126
+ if files is None:
127
+ files = self.get_repository_files()
128
+
129
+ # Filter excluded files
130
+ filtered_files = [f for f in files if not self.exclusions.match_file(f)]
131
+
132
+ # Check coverage
133
+ uncovered = [f for f in filtered_files if not self.matcher.matches(f)]
134
+
135
+ return sorted(uncovered)
136
+
137
+ def write_baseline(self, baseline_files: List[str]) -> None:
138
+ """
139
+ Write baseline file.
140
+
141
+ Args:
142
+ baseline_files: List of file paths to write to baseline
143
+ """
144
+ baseline_path = Path(self.config.baseline_path)
145
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
146
+
147
+ with open(baseline_path, "w") as f:
148
+ f.write("# CODEOWNERS Coverage Baseline\n")
149
+ f.write("# Files lacking CODEOWNERS coverage (sorted)\n")
150
+ f.write("# Goal: Reduce this list to zero\n")
151
+ f.write("#\n")
152
+ f.write("# Generated by: codeowners-coverage baseline\n")
153
+ f.write("#\n")
154
+ f.write("\n")
155
+
156
+ for filepath in baseline_files:
157
+ f.write(f"{filepath}\n")
@@ -0,0 +1,130 @@
1
+ """CLI interface for codeowners-coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import List, Tuple
8
+
9
+ import click
10
+
11
+ from .checker import CoverageChecker
12
+ from .config import Config
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(version="0.1.0")
17
+ def cli() -> None:
18
+ """CODEOWNERS coverage checking tool."""
19
+ pass
20
+
21
+
22
+ @cli.command()
23
+ @click.option("--json", "output_json", is_flag=True, help="Output JSON format")
24
+ @click.option("--files", multiple=True, help="Specific files to check")
25
+ @click.option("--config", default=".codeowners-config.yml", help="Config file path")
26
+ def check(output_json: bool, files: Tuple[str, ...], config: str) -> None:
27
+ """
28
+ Check CODEOWNERS coverage.
29
+
30
+ Validates that all files in the repository have CODEOWNERS coverage.
31
+ Files in the baseline are allowed to be uncovered.
32
+ New uncovered files will cause the check to fail.
33
+ """
34
+ try:
35
+ cfg = Config.load(config)
36
+ checker = CoverageChecker(cfg)
37
+
38
+ file_list: List[str] | None = list(files) if files else None
39
+ result = checker.check_coverage(file_list)
40
+
41
+ if output_json:
42
+ click.echo(json.dumps(result, indent=2))
43
+ else:
44
+ _print_human_readable_result(result)
45
+
46
+ # Exit with error if there are new uncovered files
47
+ if result["uncovered_files"]:
48
+ sys.exit(1)
49
+
50
+ # Exit with code 2 if baseline can be reduced (positive signal)
51
+ # Check if any baseline files are now covered
52
+ baseline_set = set(result["baseline_files"])
53
+ current_uncovered = checker.generate_baseline(file_list)
54
+ current_uncovered_set = set(current_uncovered)
55
+
56
+ if len(baseline_set - current_uncovered_set) > 0:
57
+ if not output_json:
58
+ removable_files = sorted(baseline_set - current_uncovered_set)
59
+ click.echo(f"\n🎉 Great news! {len(removable_files)} files can be removed from the baseline:")
60
+ for f in removable_files[:10]: # Show first 10
61
+ click.echo(f" - {f}")
62
+ if len(removable_files) > 10:
63
+ click.echo(f" ... and {len(removable_files) - 10} more")
64
+ click.echo("\nThese files now have CODEOWNERS coverage! Update the baseline:")
65
+ click.echo(" codeowners-coverage baseline")
66
+ sys.exit(2)
67
+
68
+ except FileNotFoundError as e:
69
+ click.echo(f"❌ Error: {e}", err=True)
70
+ sys.exit(1)
71
+ except Exception as e:
72
+ click.echo(f"❌ Unexpected error: {e}", err=True)
73
+ sys.exit(1)
74
+
75
+
76
+ @cli.command()
77
+ @click.option("--config", default=".codeowners-config.yml", help="Config file path")
78
+ @click.option("--files", multiple=True, help="Specific files to check")
79
+ def baseline(config: str, files: Tuple[str, ...]) -> None:
80
+ """
81
+ Generate or update baseline file of uncovered files.
82
+
83
+ Creates a baseline of all currently uncovered files. This allows
84
+ incremental improvement by preventing new uncovered files while
85
+ allowing existing gaps.
86
+ """
87
+ try:
88
+ cfg = Config.load(config)
89
+ checker = CoverageChecker(cfg)
90
+
91
+ file_list: List[str] | None = list(files) if files else None
92
+ baseline_files = checker.generate_baseline(file_list)
93
+
94
+ # Write baseline
95
+ checker.write_baseline(baseline_files)
96
+
97
+ click.echo(f"✅ Baseline written to {cfg.baseline_path}")
98
+ click.echo(f"📊 {len(baseline_files)} uncovered files in baseline")
99
+
100
+ if baseline_files:
101
+ click.echo(f"\n💡 Goal: Reduce this list to zero by adding CODEOWNERS coverage")
102
+
103
+ except FileNotFoundError as e:
104
+ click.echo(f"❌ Error: {e}", err=True)
105
+ sys.exit(1)
106
+ except Exception as e:
107
+ click.echo(f"❌ Unexpected error: {e}", err=True)
108
+ sys.exit(1)
109
+
110
+
111
+ def _print_human_readable_result(result: dict) -> None:
112
+ """Print coverage check result in human-readable format."""
113
+ total = result["total_files"]
114
+ covered = result["covered_files"]
115
+ uncovered = result["uncovered_files"]
116
+ baseline = result["baseline_files"]
117
+ percentage = result["coverage_percentage"]
118
+
119
+ if uncovered:
120
+ click.echo("❌ CODEOWNERS Coverage Check Failed\n")
121
+ click.echo(f"The following {len(uncovered)} files lack CODEOWNERS coverage:")
122
+ for f in uncovered:
123
+ click.echo(f" - {f}")
124
+ click.echo("\nPlease add these files to .github/CODEOWNERS with appropriate owners.")
125
+ click.echo("\n💡 Need help? Check the team mapping in the CODEOWNERS file")
126
+ click.echo(f"\n📊 Current status: {len(baseline)} files in baseline (unchanged)")
127
+ else:
128
+ click.echo(f"✅ CODEOWNERS Coverage: {percentage:.1f}% ({covered}/{total} files covered)")
129
+ if baseline:
130
+ click.echo(f"\n💡 Baseline: {len(baseline)} files still need coverage")
@@ -0,0 +1,77 @@
1
+ """Configuration loading for codeowners-coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class Config:
14
+ """Configuration for codeowners-coverage."""
15
+
16
+ codeowners_path: str = ".github/CODEOWNERS"
17
+ baseline_path: str = ".github/codeowners-coverage-baseline.txt"
18
+ exclusions: List[str] = field(default_factory=list)
19
+
20
+ @classmethod
21
+ def load(cls, config_path: str = ".codeowners-config.yml") -> Config:
22
+ """
23
+ Load configuration from YAML file.
24
+
25
+ Args:
26
+ config_path: Path to the configuration file
27
+
28
+ Returns:
29
+ Config object with loaded settings
30
+ """
31
+ config_file = Path(config_path)
32
+
33
+ if not config_file.exists():
34
+ # Return default configuration with default exclusions
35
+ return cls(exclusions=cls.default_exclusions())
36
+
37
+ with open(config_file) as f:
38
+ data = yaml.safe_load(f) or {}
39
+
40
+ return cls(
41
+ codeowners_path=data.get("codeowners_path", cls.codeowners_path),
42
+ baseline_path=data.get("baseline_path", cls.baseline_path),
43
+ exclusions=data.get("exclusions", cls.default_exclusions()),
44
+ )
45
+
46
+ @staticmethod
47
+ def default_exclusions() -> List[str]:
48
+ """
49
+ Default exclusion patterns.
50
+
51
+ These are common build artifacts and dependency directories
52
+ that typically don't need CODEOWNERS coverage.
53
+
54
+ Returns:
55
+ List of default exclusion patterns
56
+ """
57
+ return [
58
+ # Python artifacts
59
+ "**/__pycache__/**",
60
+ "**/*.pyc",
61
+ "**/*.pyo",
62
+ "**/*.egg-info/**",
63
+ ".venv/**",
64
+ ".tox/**",
65
+ ".pytest_cache/**",
66
+ ".mypy_cache/**",
67
+ # JavaScript artifacts
68
+ "node_modules/**",
69
+ "dist/**",
70
+ "build/**",
71
+ "**/coverage/**",
72
+ # Version control
73
+ ".git/**",
74
+ # Build outputs
75
+ "htmlcov/**",
76
+ ".coverage",
77
+ ]
@@ -0,0 +1,88 @@
1
+ """Pattern matching for CODEOWNERS files using pathspec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ import pathspec
9
+
10
+
11
+ class CodeOwnersPatternMatcher:
12
+ """Match file paths against CODEOWNERS patterns."""
13
+
14
+ def __init__(self, codeowners_path: str) -> None:
15
+ """
16
+ Load and parse CODEOWNERS file.
17
+
18
+ Args:
19
+ codeowners_path: Path to the CODEOWNERS file
20
+ """
21
+ self.codeowners_path = codeowners_path
22
+ self.patterns = self._parse_codeowners(codeowners_path)
23
+ # Use gitignore for gitignore-style patterns
24
+ self.spec = pathspec.PathSpec.from_lines("gitignore", self.patterns)
25
+
26
+ def _parse_codeowners(self, path: str) -> List[str]:
27
+ """
28
+ Extract patterns from CODEOWNERS file.
29
+
30
+ The CODEOWNERS format is:
31
+ pattern @owner1 @owner2
32
+
33
+ We only care about the patterns, not the owners.
34
+
35
+ Args:
36
+ path: Path to CODEOWNERS file
37
+
38
+ Returns:
39
+ List of patterns extracted from the file
40
+ """
41
+ patterns = []
42
+ codeowners_file = Path(path)
43
+
44
+ if not codeowners_file.exists():
45
+ raise FileNotFoundError(f"CODEOWNERS file not found: {path}")
46
+
47
+ with open(codeowners_file) as f:
48
+ for line in f:
49
+ line = line.strip()
50
+ # Skip comments and empty lines
51
+ if not line or line.startswith("#"):
52
+ continue
53
+
54
+ # Pattern is first token, owners follow
55
+ parts = line.split()
56
+ if parts:
57
+ pattern = parts[0]
58
+ patterns.append(pattern)
59
+
60
+ return patterns
61
+
62
+ def matches(self, filepath: str) -> bool:
63
+ """
64
+ Check if filepath is covered by any CODEOWNERS pattern.
65
+
66
+ Args:
67
+ filepath: File path to check (relative to repo root)
68
+
69
+ Returns:
70
+ True if the file matches any CODEOWNERS pattern
71
+ """
72
+ return self.spec.match_file(filepath)
73
+
74
+ def get_matching_pattern(self, filepath: str) -> str | None:
75
+ """
76
+ Get the first pattern that matches the given filepath.
77
+
78
+ Args:
79
+ filepath: File path to check (relative to repo root)
80
+
81
+ Returns:
82
+ The matching pattern, or None if no match
83
+ """
84
+ for pattern in self.patterns:
85
+ pattern_spec = pathspec.PathSpec.from_lines("gitignore", [pattern])
86
+ if pattern_spec.match_file(filepath):
87
+ return pattern
88
+ return None
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeowners-coverage
3
+ Version: 0.1.0
4
+ Summary: Measure and enforce CODEOWNERS coverage
5
+ Author-email: Sentry Team <hello@sentry.io>
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pathspec>=0.12.0
10
+ Requires-Dist: PyYAML>=6.0
11
+ Requires-Dist: click>=8.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
15
+ Requires-Dist: mypy>=1.0; extra == "dev"
16
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
17
+
18
+ # CODEOWNERS Coverage
19
+
20
+ A tool to measure and enforce CODEOWNERS coverage in your repository.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install codeowners-coverage
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Check coverage
31
+
32
+ ```bash
33
+ codeowners-coverage check
34
+ ```
35
+
36
+ ### Generate baseline
37
+
38
+ ```bash
39
+ codeowners-coverage baseline
40
+ ```
41
+
42
+ ### Configuration
43
+
44
+ Create a `.codeowners-config.yml` file in your repository root:
45
+
46
+ ```yaml
47
+ # Path to CODEOWNERS file
48
+ codeowners_path: ".github/CODEOWNERS"
49
+
50
+ # Path to baseline file
51
+ baseline_path: ".github/codeowners-coverage-baseline.txt"
52
+
53
+ # File patterns to exclude from coverage checking
54
+ exclusions:
55
+ - "**/__pycache__/**"
56
+ - "**/*.pyc"
57
+ - "node_modules/**"
58
+ - "dist/**"
59
+ - ".venv/**"
60
+ ```
61
+
62
+ ## GitHub Actions Integration
63
+
64
+ Add a workflow to check CODEOWNERS coverage on pull requests:
65
+
66
+ ```yaml
67
+ name: CODEOWNERS Coverage
68
+ on: [pull_request]
69
+
70
+ jobs:
71
+ check:
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+ - uses: actions/setup-python@v5
76
+ with:
77
+ python-version: '3.11'
78
+ - run: pip install codeowners-coverage
79
+ - run: codeowners-coverage check
80
+ ```
81
+
82
+ ## Features
83
+
84
+ - **Pattern Matching**: Supports all CODEOWNERS patterns (wildcards, globstars, etc.)
85
+ - **Baseline Support**: Track progress incrementally with a baseline allowlist
86
+ - **Configurable**: Customize exclusions and paths via YAML config
87
+ - **Fast**: Uses `pathspec` library for efficient pattern matching
88
+ - **GitHub Actions Ready**: Easy integration with CI/CD pipelines
89
+
90
+ ## Development
91
+
92
+ ### Using just (recommended)
93
+
94
+ This project includes a `justfile` with common development commands:
95
+
96
+ ```bash
97
+ # Install package in development mode
98
+ just install
99
+
100
+ # Run all tests
101
+ just test
102
+
103
+ # Run tests with coverage report
104
+ just test-cov
105
+
106
+ # Run type checking
107
+ just typecheck
108
+
109
+ # Run linting
110
+ just lint
111
+
112
+ # Run all checks (tests, typecheck, lint)
113
+ just check
114
+
115
+ # Clean build artifacts
116
+ just clean
117
+
118
+ # Build the package
119
+ just build
120
+
121
+ # Or build with uv (faster)
122
+ just build-uv
123
+
124
+ # Format code
125
+ just format
126
+
127
+ # Publish to Test PyPI
128
+ just publish-test
129
+
130
+ # Publish to production PyPI
131
+ just publish
132
+
133
+ # Full release workflow
134
+ just release
135
+ ```
136
+
137
+ ### Manual commands
138
+
139
+ ```bash
140
+ # Install in development mode
141
+ uv pip install -e ".[dev]"
142
+
143
+ # Run tests
144
+ pytest tests/ -v
145
+
146
+ # Run with coverage
147
+ pytest tests/ -v --cov
148
+
149
+ # Build package
150
+ python -m build
151
+
152
+ # Publish to PyPI
153
+ twine upload dist/*
154
+ ```
155
+
156
+ ## License
157
+
158
+ Apache-2.0
@@ -0,0 +1,11 @@
1
+ codeowners_coverage/__init__.py,sha256=-wSyZqa_AYCSXRvyuHoVavpR6HevUoUcGgyONbAg36Q,244
2
+ codeowners_coverage/__main__.py,sha256=dAxC9oNG2vWzxip2dYJL_rl0G8jqUuPu-5SMPEEplJ4,169
3
+ codeowners_coverage/checker.py,sha256=AILAiKbSGzr7j8xCJGc-QNKQ7849e8JinfA5w-XbUrE,4969
4
+ codeowners_coverage/cli.py,sha256=FBf9iaiUgiuj5-6BYC9k5ruYXQV9EQc8otVPBo_8o-c,4852
5
+ codeowners_coverage/config.py,sha256=DIlBOjE5R_bNTI0uorSCVlJZte8iUycAWkXyzH04RZ4,2143
6
+ codeowners_coverage/matcher.py,sha256=vj-TkLAqI0HUaaiNMAVcKMz6ZDmOeihil_zr9UKtGNo,2569
7
+ codeowners_coverage-0.1.0.dist-info/METADATA,sha256=0GNMBoSNMe1-y5v34G8urDeWkbEH2kEZTsHCE2AEGWc,2885
8
+ codeowners_coverage-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
9
+ codeowners_coverage-0.1.0.dist-info/entry_points.txt,sha256=luzL4lUNPBAX-alRgh9EkEXpESZ0hWdnyWhvWUaLza4,74
10
+ codeowners_coverage-0.1.0.dist-info/top_level.txt,sha256=7i-oxjSacWaiBnAPUmHtqBVCouuW65HKMgbO4yDayeg,20
11
+ codeowners_coverage-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codeowners-coverage = codeowners_coverage.__main__:main
@@ -0,0 +1 @@
1
+ codeowners_coverage