Coverage for src / bioimageio / spec / _internal / field_warning.py: 90%
68 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 13:16 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 13:16 +0000
1import dataclasses
2import inspect
3from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, get_args
5import pydantic.functional_validators
6from annotated_types import BaseMetadata, GroupedMetadata
7from loguru import logger
8from pydantic import TypeAdapter
9from pydantic_core import PydanticCustomError
10from pydantic_core.core_schema import (
11 NoInfoValidatorFunction,
12 ValidationInfo,
13 WithInfoValidatorFunction,
14)
15from typing_extensions import Annotated, LiteralString
17from .utils import SLOTS
18from .validation_context import get_validation_context
19from .warning_levels import WARNING, WarningSeverity
21if TYPE_CHECKING:
22 from pydantic.functional_validators import _V2Validator # type: ignore
25ValidatorFunction = Union[NoInfoValidatorFunction, WithInfoValidatorFunction]
27AnnotationMetaData = Union[BaseMetadata, GroupedMetadata]
30def warn(
31 typ: Union[AnnotationMetaData, Any],
32 msg: LiteralString, # warning message, e.g. "'{value}' incompatible with {typ}
33 severity: WarningSeverity = WARNING,
34):
35 """treat a type or its annotation metadata as a warning condition"""
36 if isinstance(typ, get_args(AnnotationMetaData)):
37 typ = Annotated[Any, typ]
39 validator: TypeAdapter[Any] = TypeAdapter(typ)
41 return AfterWarner(
42 validator.validate_python, severity=severity, msg=msg, context={"typ": typ}
43 )
46def _call_validator_func(
47 func: "_V2Validator",
48 mode: Literal["after", "before", "plain", "wrap"],
49 value: Any,
50 info: ValidationInfo,
51) -> Any:
52 # determine if validator needs info arg
53 # logic adapted from pydantic._internal._decorators.py v2.11.10
54 sig = inspect.signature(func)
55 parameters = list(sig.parameters.values())
56 n_positional = sum(
57 1
58 for param in parameters
59 if param.kind
60 in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
61 # First argument is the value being validated/serialized, and can have a default value
62 # (e.g. `float`, which has signature `(x=0, /)`). We assume other parameters (the info arg
63 # for instance) should be required, and thus without any default value.
64 and (param.default is inspect.Parameter.empty or param is parameters[0])
65 )
66 needs_info = None
67 if mode == "wrap":
68 if n_positional == 3:
69 needs_info = True
70 elif n_positional == 2:
71 needs_info = False
72 else:
73 assert mode in {"before", "after", "plain"}, (
74 f"invalid mode: {mode!r}, expected 'before', 'after' or 'plain"
75 )
76 if n_positional == 2:
77 needs_info = True
78 elif n_positional == 1:
79 needs_info = False
81 assert needs_info is not None, "could not determine if validator needs info arg"
82 if needs_info:
83 return func(value, info) # type: ignore
84 else:
85 return func(value) # type: ignore
88def as_warning(
89 func: "_V2Validator",
90 *,
91 mode: Literal["after", "before", "plain", "wrap"] = "after",
92 severity: WarningSeverity = WARNING,
93 msg: Optional[LiteralString] = None,
94 msg_context: Optional[Dict[str, Any]] = None,
95) -> ValidatorFunction:
96 """turn validation function into a no-op, based on warning level"""
98 def wrapper(value: Any, info: ValidationInfo) -> Any:
99 try:
100 _call_validator_func(func, mode, value, info)
101 except (AssertionError, ValueError) as e:
102 issue_warning(
103 msg or ",".join(e.args),
104 field=info.field_name,
105 log_depth=1,
106 msg_context=msg_context,
107 severity=severity,
108 value=value,
109 )
111 return value
113 return wrapper
116@dataclasses.dataclass(frozen=True, **SLOTS)
117class AfterWarner(pydantic.functional_validators.AfterValidator):
118 """Like AfterValidator, but wraps validation `func` `as_warning`"""
120 severity: WarningSeverity = WARNING
121 msg: Optional[LiteralString] = None
122 context: Optional[Dict[str, Any]] = None
124 def __post_init__(self):
125 object.__setattr__(
126 self,
127 "func",
128 as_warning(
129 self.func,
130 mode="after",
131 severity=self.severity,
132 msg=self.msg,
133 msg_context=self.context,
134 ),
135 )
138@dataclasses.dataclass(frozen=True, **SLOTS)
139class BeforeWarner(pydantic.functional_validators.BeforeValidator):
140 """Like BeforeValidator, but wraps validation `func` `as_warning`"""
142 severity: WarningSeverity = WARNING
143 msg: Optional[LiteralString] = None
144 context: Optional[Dict[str, Any]] = None
146 def __post_init__(self):
147 object.__setattr__(
148 self,
149 "func",
150 as_warning(
151 self.func,
152 mode="before",
153 severity=self.severity,
154 msg=self.msg,
155 msg_context=self.context,
156 ),
157 )
160# TODO: add `loc: Loc` to `issue_warning()`
161# and use a loguru handler to format warnings accordingly
162def issue_warning(
163 msg: LiteralString,
164 *,
165 value: Any,
166 severity: WarningSeverity = WARNING,
167 msg_context: Optional[Dict[str, Any]] = None,
168 field: Optional[str] = None,
169 log_depth: int = 1,
170):
171 msg_context = {"value": value, "severity": severity, **(msg_context or {})}
173 if severity >= (ctxt := get_validation_context()).warning_level:
174 raise PydanticCustomError("warning", msg, msg_context)
175 elif ctxt.log_warnings:
176 log_msg = (field + ": " if field else "") + (msg.format(**msg_context))
177 logger.opt(depth=log_depth).log(severity, log_msg)