Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ jobs:
- run: uv build --wheel -q # generates _templates.py via build hook
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run ty check .
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
- run: uv sync --all-extras --dev
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run ty check .

publish:
needs: [test, lint]
Expand Down
8 changes: 8 additions & 0 deletions jinjatest/coverage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
TemplateCoverage,
TemplateCoverageStats,
)
from jinjatest.coverage.types import (
BranchType,
CoverageConfig,
ReportType,
)

__all__ = [
"CoverageCollector",
Expand All @@ -72,4 +77,7 @@
"HTMLReporter",
"JUnitReporter",
"ReportConfig",
"BranchType",
"CoverageConfig",
"ReportType",
]
16 changes: 10 additions & 6 deletions jinjatest/coverage/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from jinja2 import Environment

from jinjatest.coverage.types import BranchType

if TYPE_CHECKING:
from jinja2 import nodes

Expand All @@ -21,7 +23,7 @@ class BranchInfo:
"""Information about a single branch in a template."""

branch_id: str
branch_type: str
branch_type: BranchType
line: int
description: str
has_else: bool = False
Expand Down Expand Up @@ -152,11 +154,13 @@ def _handle_if_node(

line = node.lineno
prefix = "elif" if in_elif else "if"
branch_type_true: BranchType = "elif_true" if in_elif else "if_true"
branch_type_false: BranchType = "elif_false" if in_elif else "if_false"

branches.append(
BranchInfo(
branch_id=f"{prefix}_{line}_true",
branch_type=f"{prefix}_true",
branch_type=branch_type_true,
line=line,
description=f"{prefix} condition at line {line} is true",
)
Expand All @@ -182,7 +186,7 @@ def _handle_if_node(
branches.append(
BranchInfo(
branch_id=f"{prefix}_{line}_false",
branch_type=f"{prefix}_false",
branch_type=branch_type_false,
line=line,
description=f"{prefix} condition at line {line} is false (else taken)",
has_else=True,
Expand All @@ -192,7 +196,7 @@ def _handle_if_node(
branches.append(
BranchInfo(
branch_id=f"{prefix}_{line}_false",
branch_type=f"{prefix}_false",
branch_type=branch_type_false,
line=line,
description=f"{prefix} condition at line {line} is false (no else)",
has_else=False,
Expand All @@ -203,7 +207,7 @@ def _handle_if_node(
branches.append(
BranchInfo(
branch_id=f"{prefix}_{line}_false",
branch_type=f"{prefix}_false",
branch_type=branch_type_false,
line=line,
description=f"{prefix} condition at line {line} is false (else taken)",
has_else=True,
Expand All @@ -213,7 +217,7 @@ def _handle_if_node(
branches.append(
BranchInfo(
branch_id=f"{prefix}_{line}_false",
branch_type=f"{prefix}_false",
branch_type=branch_type_false,
line=line,
description=f"{prefix} condition at line {line} is false (no else)",
has_else=False,
Expand Down
67 changes: 37 additions & 30 deletions jinjatest/coverage/pytest_cov.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,31 @@
from __future__ import annotations

from pathlib import Path
from types import ModuleType
from typing import Any

import pytest
from pydantic import ValidationError

from jinjatest.coverage.collector import (
get_coverage_collector,
reset_coverage_collector,
)
from jinjatest.coverage.reporter import CoverageReporter, ReportConfig
from jinjatest.coverage.types import (
CoverageConfig,
ReportType,
_normalize_and_validate_report_type,
)


def _load_pyproject_config() -> dict[str, Any]:
def _load_pyproject_config() -> CoverageConfig:
"""Load coverage configuration from pyproject.toml.

Returns:
Dictionary of coverage settings or empty dict if not found.
CoverageConfig instance with settings from pyproject.toml or defaults.
"""
tomllib: Any = None
tomllib: ModuleType | None = None
try:
import tomllib as _tomllib # type: ignore[import-not-found]

Expand All @@ -35,18 +42,21 @@ def _load_pyproject_config() -> dict[str, Any]:

tomllib = _tomli
except ImportError:
return {}
return CoverageConfig()

pyproject_path = Path("pyproject.toml")
if not pyproject_path.exists():
return {}
return CoverageConfig()

try:
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return data.get("tool", {}).get("jinjatest", {}).get("coverage", {})
except Exception:
return {}
raw_config: dict[str, Any] = (
data.get("tool", {}).get("jinjatest", {}).get("coverage", {})
)
return CoverageConfig.model_validate(raw_config)
except (ValidationError, Exception):
return CoverageConfig()


def pytest_addoption(parser: pytest.Parser) -> None:
Expand Down Expand Up @@ -117,23 +127,22 @@ def pytest_configure(config: pytest.Config) -> None:
"""Configure coverage collection if enabled."""
pyproject_config = _load_pyproject_config()

cov_enabled = config.getoption("--jt-cov") or pyproject_config.get("enabled", False)
cov_enabled = config.getoption("--jt-cov") or pyproject_config.enabled

if cov_enabled:
collector = get_coverage_collector()
collector.enable()

cli_excludes = config.getoption("--jt-cov-exclude", [])
config_excludes = pyproject_config.get("exclude_patterns", [])
all_excludes = cli_excludes + config_excludes
cli_excludes: list[str] = config.getoption("--jt-cov-exclude", [])
all_excludes = cli_excludes + pyproject_config.exclude_patterns
if all_excludes:
collector.set_exclude_patterns(all_excludes)

config._jt_cov_enabled = True # type: ignore[attr-defined]
config._jt_cov_pyproject = pyproject_config # type: ignore[attr-defined]
else:
config._jt_cov_enabled = False # type: ignore[attr-defined]
config._jt_cov_pyproject = {} # type: ignore[attr-defined]
config._jt_cov_pyproject = CoverageConfig() # type: ignore[attr-defined]


def pytest_unconfigure(config: pytest.Config) -> None:
Expand All @@ -153,24 +162,24 @@ def pytest_sessionfinish(

collector = get_coverage_collector()
summary = collector.get_summary()
pyproject_config = getattr(config, "_jt_cov_pyproject", {})

cli_fail_under = config.getoption("--jt-cov-fail-under", 0.0)
fail_under = (
cli_fail_under
if cli_fail_under > 0
else pyproject_config.get("fail_under", 0.0)
pyproject_config: CoverageConfig = getattr(
config, "_jt_cov_pyproject", CoverageConfig()
)

cli_report_types = config.getoption("--jt-cov-report", [])
config_report_types = pyproject_config.get("report", [])
report_types = cli_report_types if cli_report_types else config_report_types
cli_fail_under: float = config.getoption("--jt-cov-fail-under", 0.0)
fail_under = cli_fail_under if cli_fail_under > 0 else pyproject_config.fail_under

cli_report_types: list[str] = config.getoption("--jt-cov-report", [])
if cli_report_types:
report_types: list[ReportType] = [
_normalize_and_validate_report_type(rt) for rt in cli_report_types
]
else:
report_types = pyproject_config.report

if not report_types:
report_types = ["term"]

report_types = [r.lower() for r in report_types]

report_config = ReportConfig(
fail_under=fail_under,
show_missing=True,
Expand All @@ -194,16 +203,14 @@ def pytest_sessionfinish(

if "json" in report_types:
cli_json_path = config.getoption("--jt-cov-json")
json_path = cli_json_path or pyproject_config.get(
"json_file", "jt-coverage.json"
)
json_path = cli_json_path or pyproject_config.json_file
reporter.json_report(summary, Path(json_path))
if output:
output.line(f"JSON report written to: {json_path}")

if "html" in report_types:
cli_html_dir = config.getoption("--jt-cov-html")
html_dir = cli_html_dir or pyproject_config.get("html_dir", "jt-htmlcov")
html_dir = cli_html_dir or pyproject_config.html_dir
sources: dict[str, str] = {}
for path, tracker in collector.get_all_trackers().items():
if tracker.source:
Expand All @@ -214,7 +221,7 @@ def pytest_sessionfinish(

if "xml" in report_types:
cli_xml_path = config.getoption("--jt-cov-xml")
xml_path = cli_xml_path or pyproject_config.get("xml_file", "jt-coverage.xml")
xml_path = cli_xml_path or pyproject_config.xml_file
reporter.junit_report(summary, Path(xml_path))
if output:
output.line(f"JUnit XML report written to: {xml_path}")
Expand Down
4 changes: 2 additions & 2 deletions jinjatest/coverage/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, TextIO
from typing import TYPE_CHECKING, Any, TextIO

from jinjatest.coverage._templates import (
INDEX_HTML,
Expand Down Expand Up @@ -230,7 +230,7 @@ def write_to_file(self, summary: CoverageSummary, path: Path) -> None:
with open(path, "w") as f:
self.report(summary, f)

def _template_to_dict(self, stats: TemplateCoverageStats) -> dict:
def _template_to_dict(self, stats: TemplateCoverageStats) -> dict[str, Any]:
"""Convert template stats to a dictionary.

Args:
Expand Down
89 changes: 89 additions & 0 deletions jinjatest/coverage/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
Type definitions for the coverage module.

This module provides type literals and Pydantic models for improved type safety.
"""

from __future__ import annotations

from typing import Annotated, Any, Literal, get_args

from pydantic import BaseModel, BeforeValidator, Field

ReportType = Literal["term", "term-missing", "term-verbose", "json", "html", "xml"]

BranchType = Literal[
"if_true",
"if_false",
"elif_true",
"elif_false",
"for_body",
"for_else",
"macro",
"include",
"block",
"cond_true",
"cond_false",
]

_VALID_REPORT_TYPES: frozenset[str] = frozenset(get_args(ReportType))


def _normalize_and_validate_report_type(value: Any) -> ReportType:
"""Normalize and validate a single report type value.

Args:
value: The report type value to validate.

Returns:
The normalized report type.

Raises:
ValueError: If the value is not a valid report type.
"""
if not isinstance(value, str):
raise ValueError(f"Report type must be a string, got {type(value).__name__}")
normalized = value.lower()
if normalized not in _VALID_REPORT_TYPES:
valid_list = ", ".join(sorted(_VALID_REPORT_TYPES))
raise ValueError(
f"Invalid report type '{value}'. Valid types are: {valid_list}"
)
return normalized


def _normalize_report_type_list(value: Any) -> list[ReportType]:
"""Normalize and validate a list of report types.

Args:
value: The list of report types to validate.

Returns:
List of normalized report types.

Raises:
ValueError: If any value is not a valid report type.
"""
if not isinstance(value, list):
raise ValueError(f"Report types must be a list, got {type(value).__name__}")
return [_normalize_and_validate_report_type(v) for v in value]


ValidatedReportTypeList = Annotated[
list[ReportType],
BeforeValidator(_normalize_report_type_list),
]


class CoverageConfig(BaseModel):
"""Configuration for coverage collection from pyproject.toml."""

enabled: bool = False
fail_under: float = Field(default=0.0, ge=0.0, le=100.0)
report: ValidatedReportTypeList = Field(default_factory=list)
exclude_patterns: list[str] = Field(default_factory=list)
html_dir: str = "jt-htmlcov"
json_file: str = "jt-coverage.json"
xml_file: str = "jt-coverage.xml"

model_config = {"extra": "ignore"}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ include = ["tests/"]
[tool.ty.overrides.rules]
# Tests frequently access attributes on union types after assertions narrow the type
possibly-missing-attribute = "ignore"
unresolved-attribute = "ignore"
# Tests often use operators on optional types where context guarantees validity
unsupported-operator = "warn"
# Allow more flexible argument types in test code (e.g., MagicMock for pytest types)
Expand Down
Loading