"""Test distribution statistics."""
from __future__ import annotations
from typing import (
TYPE_CHECKING,
ClassVar,
Final,
)
from beartype import beartype
from icontract import (
ensure,
require,
)
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
if TYPE_CHECKING:
from collections.abc import Mapping
from pytest_test_categories.distribution.config import DistributionConfig
from pytest_test_categories.types import TestSize
[docs]
ONE_HUNDRED_PERCENT: Final[float] = 100.0
[docs]
class DistributionRange(BaseModel):
"""Valid range for a test size distribution percentage."""
[docs]
target: float = Field(ge=0.0, le=ONE_HUNDRED_PERCENT)
[docs]
tolerance: float = Field(gt=0.0, le=20.0)
[docs]
model_config = ConfigDict(frozen=True)
@property
[docs]
def min_value(self) -> float:
"""Minimum acceptable percentage."""
return max(0.0, self.target - self.tolerance)
@property
[docs]
def max_value(self) -> float:
"""Maximum acceptable percentage."""
return min(ONE_HUNDRED_PERCENT, self.target + self.tolerance)
[docs]
DISTRIBUTION_TARGETS = {
'small': DistributionRange(target=80.0, tolerance=5.0), # 75-85%
'medium': DistributionRange(target=15.0, tolerance=5.0), # 10-20%
'large_xlarge': DistributionRange(target=5.0, tolerance=3.0), # 2-8%
}
[docs]
class TestCounts(BaseModel):
"""Count of tests by size."""
[docs]
small: int = Field(default=0, ge=0)
[docs]
medium: int = Field(default=0, ge=0)
[docs]
large: int = Field(default=0, ge=0)
[docs]
xlarge: int = Field(default=0, ge=0)
[docs]
model_config = ConfigDict(frozen=True)
[docs]
class TestPercentages(BaseModel):
"""Distribution percentages of tests by size."""
_TOTAL_ERROR: ClassVar[str] = 'Percentages must sum to 100% (within rounding error) unless all are 0'
# Tolerance needs to account for up to 4 values each potentially being off by 0.005 after rounding
# Maximum error from rounding 4 values: 4 * 0.005 = 0.02
# Using 0.03 to provide a small safety margin
[docs]
ROUNDING_TOLERANCE: ClassVar[float] = 0.03
[docs]
small: float = Field(ge=0.0, le=ONE_HUNDRED_PERCENT, default=0.0)
[docs]
medium: float = Field(ge=0.0, le=ONE_HUNDRED_PERCENT, default=0.0)
[docs]
large: float = Field(ge=0.0, le=ONE_HUNDRED_PERCENT, default=0.0)
[docs]
xlarge: float = Field(ge=0.0, le=ONE_HUNDRED_PERCENT, default=0.0)
@field_validator('small', 'medium', 'large', 'xlarge', mode='before')
@classmethod
[docs]
def round_to_two_decimals(cls: type[TestPercentages], v: float) -> float:
"""Round percentage values to two decimal places."""
return round(float(v), 2)
@model_validator(mode='after')
[docs]
def validate_total(self) -> TestPercentages:
"""Validate that percentages sum to 100% unless all are 0.
Returns:
The validated TestPercentages instance.
Raises:
ValueError: If percentages don't sum to 100% (within rounding tolerance).
"""
values = [self.small, self.medium, self.large, self.xlarge]
total = sum(values)
if not (all(x == 0.0 for x in values) or abs(total - ONE_HUNDRED_PERCENT) <= self.ROUNDING_TOLERANCE):
raise ValueError(self._TOTAL_ERROR)
return self
[docs]
class DistributionStats(BaseModel):
"""Test distribution statistics."""
_RANGE_ERROR = '{name} test percentage ({value:.2f}%) outside target range {min:.2f}%-{max:.2f}%'
[docs]
counts: TestCounts = Field(default_factory=TestCounts)
[docs]
model_config = ConfigDict(frozen=True)
@classmethod
[docs]
def update_counts(cls: type[DistributionStats], counts: Mapping[TestSize, int] | TestCounts) -> DistributionStats:
"""Return a new instance with updated counts."""
return cls(counts=TestCounts.model_validate(counts))
@beartype
@ensure(lambda result: isinstance(result, TestPercentages), 'Must return TestPercentages')
[docs]
def calculate_percentages(self) -> TestPercentages:
"""Calculate the percentage distribution of test sizes."""
total = self.counts.small + self.counts.medium + self.counts.large + self.counts.xlarge
if total == 0:
return TestPercentages()
return TestPercentages(
small=(self.counts.small * 100.0) / total,
medium=(self.counts.medium * 100.0) / total,
large=(self.counts.large * 100.0) / total,
xlarge=(self.counts.xlarge * 100.0) / total,
)
@beartype
@require(lambda value: 0.0 <= value <= ONE_HUNDRED_PERCENT, 'Percentage value must be between 0 and 100')
def _validate_range(self, value: float, target_range: DistributionRange, name: str) -> None:
"""Validate a percentage value against its target range."""
if not target_range.min_value <= value <= target_range.max_value:
raise ValueError(
self._RANGE_ERROR.format(
name=name,
value=value,
min=target_range.min_value,
max=target_range.max_value,
)
)
[docs]
def validate_distribution(self, config: DistributionConfig | None = None) -> None:
"""Validate test distribution against target ranges.
Args:
config: Optional DistributionConfig with custom targets and tolerances.
If not provided, uses DEFAULT_DISTRIBUTION_CONFIG.
Raises:
ValueError: If the distribution is outside the configured target ranges.
"""
if config is None:
from pytest_test_categories.distribution.config import DEFAULT_DISTRIBUTION_CONFIG # noqa: PLC0415
effective_config = DEFAULT_DISTRIBUTION_CONFIG
else:
effective_config = config
percentages = self.calculate_percentages()
self._validate_range(percentages.small, effective_config.get_small_range(), 'Small')
self._validate_range(percentages.medium, effective_config.get_medium_range(), 'Medium')
self._validate_range(
percentages.large + percentages.xlarge, effective_config.get_large_xlarge_range(), 'Large/XLarge'
)