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