Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/scripts/check_crap_threshold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Fail when pytest-crap reports a CRAP score at or above a threshold."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

from coverage import CoverageData
from pytest_crap.calculator import calculate_crap


def covered_lines_for_file(coverage_file: Path, source_file: Path) -> set[int]:
data = CoverageData(basename=str(coverage_file))
data.read()

source_file = source_file.resolve()
for measured_file in data.measured_files():
if Path(measured_file).resolve() == source_file:
return set(data.lines(measured_file) or [])

raise SystemExit(f"No coverage data found for {source_file}")


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source_file", type=Path)
parser.add_argument("--coverage-file", type=Path, default=Path(".coverage"))
parser.add_argument("--max-crap", type=float, required=True)
args = parser.parse_args()

covered_lines = covered_lines_for_file(args.coverage_file, args.source_file)
scores = calculate_crap(str(args.source_file), covered_lines)
offenders = [score for score in scores if score.crap >= args.max_crap]

if not offenders:
print(f"All CRAP scores are below {args.max_crap:g} for {args.source_file}")
return 0

print(f"CRAP scores must be below {args.max_crap:g} for {args.source_file}")
for score in sorted(offenders, key=lambda item: item.crap, reverse=True):
print(
f"{score.name}: CRAP={score.crap:.2f}, "
f"CC={score.cc}, coverage={score.coverage_percent:.1f}%"
)
return 1


if __name__ == "__main__":
sys.exit(main())
84 changes: 84 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,90 @@ jobs:
run: |
pytest --verbose --timeout=30

mutation-tests:
name: Targeted mutation tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 0

- name: Check targeted mutation inputs changed
id: changes
shell: bash
run: |
set -euo pipefail

if [[ "${{ github.event_name }}" == "pull_request" ]]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
else
base="${{ github.event.before }}"
head="${{ github.sha }}"
fi

changed_files="$(git diff --name-only "$base" "$head" -- \
posthog/utils.py \
posthog/test/test_utils.py \
posthog/test/test_size_limited_dict.py)"

if [[ -n "$changed_files" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Targeted mutation inputs changed:"
echo "$changed_files"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Skipping targeted mutation tests: inputs unchanged."
fi

- name: Set up Python 3.11
if: steps.changes.outputs.changed == 'true'
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11

- name: Install uv
if: steps.changes.outputs.changed == 'true'
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: true

- name: Check utils CRAP score
if: steps.changes.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov pytest posthog/test/test_utils.py posthog/test/test_size_limited_dict.py --timeout=30 --cov=posthog.utils --crap --crap-threshold=10 --crap-top-n=40 -q
UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with pytest-crap --with pytest-cov python .github/scripts/check_crap_threshold.py posthog/utils.py --max-crap 10

- name: Restore mutmut cache
id: mutmut-cache
if: steps.changes.outputs.changed == 'true'
uses: actions/cache@v4
with:
path: mutants
key: mutmut-${{ runner.os }}-py311-${{ hashFiles('posthog/utils.py', 'posthog/test/test_utils.py', 'posthog/test/test_size_limited_dict.py') }}
restore-keys: |
mutmut-${{ runner.os }}-py311-

- name: Skip mutation tests on exact cache hit
if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit == 'true'
run: |
echo "Skipping targeted mutation tests: exact mutmut cache hit."

- name: Run targeted mutation tests
if: steps.changes.outputs.changed == 'true' && steps.mutmut-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut run --max-children 1
results="$(UV_PROJECT_ENVIRONMENT=$pythonLocation uv run --extra test --with mutmut mutmut results)"
if [[ -n "$results" ]]; then
echo "$results"
exit 1
fi

import-check:
name: Python ${{ matrix.python-version }} import check
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ pyrightconfig.json
.DS_Store
posthog-python-references.json
.claude/settings.local.json
mutants/
10 changes: 10 additions & 0 deletions posthog/test/test_size_limited_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ def test_size_limited_dict(self, size: int, iterations: int) -> None:
self.assertIsNone(values.get(i - 3))
self.assertIsNone(values.get(i - 5))
self.assertIsNone(values.get(i - 9))

def test_size_limited_dict_forwards_defaultdict_args_and_kwargs(self) -> None:
values = utils.SizeLimitedDict(
3, lambda: "missing", {"existing": "value"}, other="item"
)

assert values["missing"] == "missing"
assert values["existing"] == "value"
assert values["other"] == "item"
assert values.max_size == 3
Loading
Loading