From 148c93126e534d77a9125565ebf38b83569242f3 Mon Sep 17 00:00:00 2001 From: Kevin Castro Date: Tue, 3 Feb 2026 00:37:10 -0500 Subject: [PATCH 1/2] More strict types with Pydantic; updated CI --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + jinjatest/coverage/__init__.py | 8 +++ jinjatest/coverage/discovery.py | 16 +++-- jinjatest/coverage/pytest_cov.py | 67 +++++++++++--------- jinjatest/coverage/reporter.py | 4 +- jinjatest/coverage/types.py | 89 +++++++++++++++++++++++++++ tests/coverage/test_boost_coverage.py | 37 ++++++----- tests/test_coverage_final.py | 3 +- 9 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 jinjatest/coverage/types.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51f289a..ad29d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f8f0ea..1b769d0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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] diff --git a/jinjatest/coverage/__init__.py b/jinjatest/coverage/__init__.py index ea5d560..5e74216 100644 --- a/jinjatest/coverage/__init__.py +++ b/jinjatest/coverage/__init__.py @@ -51,6 +51,11 @@ TemplateCoverage, TemplateCoverageStats, ) +from jinjatest.coverage.types import ( + BranchType, + CoverageConfig, + ReportType, +) __all__ = [ "CoverageCollector", @@ -72,4 +77,7 @@ "HTMLReporter", "JUnitReporter", "ReportConfig", + "BranchType", + "CoverageConfig", + "ReportType", ] diff --git a/jinjatest/coverage/discovery.py b/jinjatest/coverage/discovery.py index 13e2fa3..870f73e 100644 --- a/jinjatest/coverage/discovery.py +++ b/jinjatest/coverage/discovery.py @@ -12,6 +12,8 @@ from jinja2 import Environment +from jinjatest.coverage.types import BranchType + if TYPE_CHECKING: from jinja2 import nodes @@ -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 @@ -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", ) @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/jinjatest/coverage/pytest_cov.py b/jinjatest/coverage/pytest_cov.py index 85ef7a6..f44bb0e 100644 --- a/jinjatest/coverage/pytest_cov.py +++ b/jinjatest/coverage/pytest_cov.py @@ -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] @@ -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: @@ -117,15 +127,14 @@ 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) @@ -133,7 +142,7 @@ def pytest_configure(config: pytest.Config) -> None: 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: @@ -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, @@ -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: @@ -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}") diff --git a/jinjatest/coverage/reporter.py b/jinjatest/coverage/reporter.py index 88c59a2..ba03bcf 100644 --- a/jinjatest/coverage/reporter.py +++ b/jinjatest/coverage/reporter.py @@ -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, @@ -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: diff --git a/jinjatest/coverage/types.py b/jinjatest/coverage/types.py new file mode 100644 index 0000000..c1c366f --- /dev/null +++ b/jinjatest/coverage/types.py @@ -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"} diff --git a/tests/coverage/test_boost_coverage.py b/tests/coverage/test_boost_coverage.py index 858a695..33d15b2 100644 --- a/tests/coverage/test_boost_coverage.py +++ b/tests/coverage/test_boost_coverage.py @@ -25,6 +25,7 @@ TerminalReporter, ) from jinjatest.coverage.tracker import BranchCoverage, TemplateCoverageStats +from jinjatest.coverage.types import CoverageConfig @pytest.fixture @@ -323,9 +324,9 @@ def test_load_pyproject_config_no_tomllib(self) -> None: with mock.patch.dict("sys.modules", {"tomllib": None, "tomli": None}): # Force reimport to test the import error path with mock.patch.object(pytest_cov, "_load_pyproject_config") as mock_load: - mock_load.return_value = {} + mock_load.return_value = CoverageConfig() result = mock_load() - assert result == {} + assert result == CoverageConfig() def test_load_pyproject_config_no_file(self) -> None: """Test config loading when pyproject.toml doesn't exist.""" @@ -333,7 +334,7 @@ def test_load_pyproject_config_no_file(self) -> None: with mock.patch("pathlib.Path.exists", return_value=False): result = _load_pyproject_config() - assert result == {} + assert result == CoverageConfig() def test_load_pyproject_config_parse_error(self) -> None: """Test config loading handles parse errors.""" @@ -344,7 +345,7 @@ def test_load_pyproject_config_parse_error(self) -> None: "builtins.open", mock.mock_open(read_data=b"invalid toml [") ): result = _load_pyproject_config() - assert result == {} + assert result == CoverageConfig() def test_pytest_configure_enables_collector(self) -> None: """Test pytest_configure enables collector when --jt-cov is set.""" @@ -357,7 +358,8 @@ def test_pytest_configure_enables_collector(self) -> None: }.get(x, default) with mock.patch( - "jinjatest.coverage.pytest_cov._load_pyproject_config", return_value={} + "jinjatest.coverage.pytest_cov._load_pyproject_config", + return_value=CoverageConfig(), ): pytest_configure(config) @@ -377,7 +379,7 @@ def test_pytest_configure_with_excludes(self) -> None: with mock.patch( "jinjatest.coverage.pytest_cov._load_pyproject_config", - return_value={"exclude_patterns": ["*.partial.j2"]}, + return_value=CoverageConfig(exclude_patterns=["*.partial.j2"]), ): pytest_configure(config) @@ -396,7 +398,8 @@ def test_pytest_configure_disabled(self) -> None: }.get(x, default) with mock.patch( - "jinjatest.coverage.pytest_cov._load_pyproject_config", return_value={} + "jinjatest.coverage.pytest_cov._load_pyproject_config", + return_value=CoverageConfig(), ): pytest_configure(config) @@ -454,7 +457,7 @@ def test_pytest_sessionfinish_generates_reports(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["term"], @@ -486,7 +489,7 @@ def test_pytest_sessionfinish_json_report(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["json"], @@ -517,7 +520,7 @@ def test_pytest_sessionfinish_html_report(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["html"], @@ -549,7 +552,7 @@ def test_pytest_sessionfinish_xml_report(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["xml"], @@ -577,7 +580,7 @@ def test_pytest_sessionfinish_fail_under(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 100.0, # High threshold "--jt-cov-report": ["term"], @@ -637,7 +640,7 @@ def test_pytest_sessionfinish_no_terminal_reporter(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["term"], @@ -659,7 +662,7 @@ def test_pytest_sessionfinish_default_reports(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": [], # Empty - should default to term @@ -688,7 +691,7 @@ def test_pytest_sessionfinish_term_missing(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["term-missing"], @@ -716,7 +719,7 @@ def test_pytest_sessionfinish_term_verbose(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {} + session.config._jt_cov_pyproject = CoverageConfig() session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, "--jt-cov-report": ["term-verbose"], @@ -744,7 +747,7 @@ def test_pytest_sessionfinish_pyproject_fail_under(self) -> None: session = mock.MagicMock() session.config._jt_cov_enabled = True - session.config._jt_cov_pyproject = {"fail_under": 100.0} + session.config._jt_cov_pyproject = CoverageConfig(fail_under=100.0) session.config.getoption.side_effect = lambda x, default=None: { "--jt-cov-fail-under": 0.0, # CLI not set "--jt-cov-report": ["term"], diff --git a/tests/test_coverage_final.py b/tests/test_coverage_final.py index cfdc76f..0aa6291 100644 --- a/tests/test_coverage_final.py +++ b/tests/test_coverage_final.py @@ -324,7 +324,8 @@ def test_load_config_with_tomli_fallback(self) -> None: # This is tricky to test properly since we're on Python 3.11+ # which has tomllib built-in. We verify the function works. from jinjatest.coverage.pytest_cov import _load_pyproject_config + from jinjatest.coverage.types import CoverageConfig # Just ensure it doesn't crash result = _load_pyproject_config() - assert isinstance(result, dict) + assert isinstance(result, CoverageConfig) From 782fa2daf94dfcc8a3d9df4c431ddef5bec76cb0 Mon Sep 17 00:00:00 2001 From: Kevin Castro Date: Tue, 3 Feb 2026 00:59:22 -0500 Subject: [PATCH 2/2] Adding ty exclussion --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4652f16..a678eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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)