Coverage for src / bluetooth_sig / gatt / validation.py: 82%
92 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Enhanced validation utilities for strict type checking and data validation.
3This module provides additional validation capabilities beyond the basic
4utils, focusing on strict type safety and comprehensive data integrity
5checks.
6"""
8from __future__ import annotations
10from typing import Any, Callable, TypeVar
12import msgspec
14from .characteristics.utils.ieee11073_parser import IEEE11073Parser
15from .constants import (
16 ABSOLUTE_ZERO_CELSIUS,
17 MAX_CONCENTRATION_PPM,
18 MAX_POWER_WATTS,
19 MAX_TEMPERATURE_CELSIUS,
20 PERCENTAGE_MAX,
21)
22from .exceptions import DataValidationError, TypeMismatchError, ValueRangeError
24T = TypeVar("T")
27class ValidationRule(msgspec.Struct, kw_only=True):
28 """Represents a validation rule with optional custom validator."""
30 field_name: str
31 expected_type: type | tuple[type, ...] # Allow tuple of types for isinstance
32 min_value: int | float | None = None
33 max_value: int | float | None = None
34 custom_validator: Callable[[Any], bool] | None = None
35 error_message: str | None = None
37 def validate(self, value: Any) -> None: # noqa: ANN401 # Validates values of various types
38 """Apply this validation rule to a value."""
39 # Type check
40 if not isinstance(value, self.expected_type):
41 raise TypeMismatchError(self.field_name, value, self.expected_type)
43 # Range check
44 if self.min_value is not None or self.max_value is not None:
45 min_val = self.min_value if self.min_value is not None else float("-inf")
46 max_val = self.max_value if self.max_value is not None else float("inf")
47 if not min_val <= value <= max_val:
48 raise ValueRangeError(self.field_name, value, min_val, max_val)
50 # Custom validation
51 if self.custom_validator:
52 # Note: custom_validator is type-hinted as Callable, but pylint doesn't recognize this
53 if not self.custom_validator(value): # pylint: disable=not-callable
54 message = self.error_message or f"Custom validation failed for {self.field_name}"
55 raise DataValidationError(self.field_name, value, message)
58class StrictValidator(msgspec.Struct, kw_only=True):
59 """Strict validation engine for complex data structures."""
61 rules: dict[str, ValidationRule] = msgspec.field(default_factory=dict)
63 def add_rule(self, rule: ValidationRule) -> None:
64 """Add a validation rule."""
65 self.rules[rule.field_name] = rule
67 def validate_dict(self, data: dict[str, Any]) -> None:
68 """Validate a dictionary against all rules."""
69 for field_name, value in data.items():
70 if field_name in self.rules:
71 self.rules[field_name].validate(value)
73 def validate_object(self, obj: Any) -> None: # noqa: ANN401 # Validates objects of any type
74 """Validate an object's attributes against all rules."""
75 for field_name, rule in self.rules.items():
76 if hasattr(obj, field_name):
77 value = getattr(obj, field_name)
78 rule.validate(value)
81class CommonValidators:
82 """Collection of commonly used validation functions."""
84 @staticmethod
85 def is_positive(value: int | float) -> bool:
86 """Check if value is positive."""
87 return value > 0
89 @staticmethod
90 def is_non_negative(value: int | float) -> bool:
91 """Check if value is non-negative."""
92 return value >= 0
94 @staticmethod
95 def is_valid_percentage(value: int | float) -> bool:
96 """Check if value is a valid percentage (0-100)."""
97 return 0 <= value <= PERCENTAGE_MAX
99 @staticmethod
100 def is_valid_extended_percentage(value: int | float) -> bool:
101 """Check if value is a valid extended percentage (0-200)."""
102 return 0 <= value <= 200
104 @staticmethod
105 def is_physical_temperature(value: float) -> bool:
106 """Check if temperature is physically reasonable."""
107 return ABSOLUTE_ZERO_CELSIUS <= value <= MAX_TEMPERATURE_CELSIUS
109 @staticmethod
110 def is_valid_concentration(value: float) -> bool:
111 """Check if concentration is in valid range."""
112 return 0 <= value <= MAX_CONCENTRATION_PPM
114 @staticmethod
115 def is_valid_power(value: int | float) -> bool:
116 """Check if power value is reasonable."""
117 return 0 <= value <= MAX_POWER_WATTS
119 @staticmethod
120 def is_valid_heart_rate(value: int) -> bool:
121 """Check if heart rate is in human range."""
122 return 30 <= value <= 300 # Reasonable human heart rate range
124 @staticmethod
125 def is_valid_battery_level(value: int) -> bool:
126 """Check if battery level is valid percentage."""
127 return 0 <= value <= PERCENTAGE_MAX
129 @staticmethod
130 def is_ieee11073_special_value(value: int) -> bool:
131 """Check if value is a valid IEEE 11073 special value."""
132 special_values = {
133 IEEE11073Parser.SFLOAT_NAN,
134 IEEE11073Parser.SFLOAT_NRES,
135 IEEE11073Parser.SFLOAT_POSITIVE_INFINITY,
136 IEEE11073Parser.SFLOAT_NEGATIVE_INFINITY,
137 }
138 return value in special_values
141# Pre-configured validators for common use cases
142HEART_RATE_VALIDATOR = StrictValidator()
143HEART_RATE_VALIDATOR.add_rule(
144 ValidationRule(
145 field_name="heart_rate",
146 expected_type=int,
147 min_value=30,
148 max_value=300,
149 custom_validator=CommonValidators.is_valid_heart_rate,
150 error_message="Heart rate must be between 30-300 bpm",
151 )
152)
154BATTERY_VALIDATOR = StrictValidator()
155BATTERY_VALIDATOR.add_rule(
156 ValidationRule(
157 field_name="battery_level",
158 expected_type=int,
159 min_value=0,
160 max_value=PERCENTAGE_MAX,
161 custom_validator=CommonValidators.is_valid_battery_level,
162 error_message="Battery level must be 0-100%",
163 )
164)
166TEMPERATURE_VALIDATOR = StrictValidator()
167TEMPERATURE_VALIDATOR.add_rule(
168 ValidationRule(
169 field_name="temperature",
170 expected_type=float,
171 min_value=ABSOLUTE_ZERO_CELSIUS,
172 max_value=MAX_TEMPERATURE_CELSIUS,
173 custom_validator=CommonValidators.is_physical_temperature,
174 error_message="Temperature must be physically reasonable",
175 )
176)
179def create_range_validator(
180 field_name: str,
181 expected_type: type,
182 min_value: int | float | None = None,
183 max_value: int | float | None = None,
184 custom_validator: Callable[[Any], bool] | None = None,
185) -> StrictValidator:
186 """Factory function to create a validator for a specific range."""
187 validator = StrictValidator()
188 validator.add_rule(
189 ValidationRule(
190 field_name=field_name,
191 expected_type=expected_type,
192 min_value=min_value,
193 max_value=max_value,
194 custom_validator=custom_validator,
195 )
196 )
197 return validator
200def validate_measurement_data(data: dict[str, Any], measurement_type: str) -> dict[str, Any]:
201 """Validate measurement data based on type and return validated data."""
202 if measurement_type == "heart_rate":
203 HEART_RATE_VALIDATOR.validate_dict(data)
204 elif measurement_type == "battery":
205 BATTERY_VALIDATOR.validate_dict(data)
206 elif measurement_type == "temperature":
207 TEMPERATURE_VALIDATOR.validate_dict(data)
208 else:
209 # Generic validation for unknown types
210 for key, value in data.items():
211 if isinstance(value, (int, float)) and value < 0:
212 raise ValueRangeError(key, value, 0, float("inf"))
214 return data