Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pydantic/_internal/_known_annotated_metadata.py: 54%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

163 statements  

1from __future__ import annotations 

2 

3from collections import defaultdict 

4from collections.abc import Iterable 

5from copy import copy 

6from functools import lru_cache, partial 

7from typing import TYPE_CHECKING, Any 

8 

9from pydantic_core import CoreSchema, PydanticCustomError, ValidationError, to_jsonable_python 

10from pydantic_core import core_schema as cs 

11 

12from ._fields import PydanticMetadata 

13from ._import_utils import import_cached_field_info 

14 

15if TYPE_CHECKING: 

16 pass 

17 

18STRICT = {'strict'} 

19FAIL_FAST = {'fail_fast'} 

20LENGTH_CONSTRAINTS = {'min_length', 'max_length'} 

21INEQUALITY = {'le', 'ge', 'lt', 'gt'} 

22NUMERIC_CONSTRAINTS = {'multiple_of', *INEQUALITY} 

23ALLOW_INF_NAN = {'allow_inf_nan'} 

24 

25STR_CONSTRAINTS = { 

26 *LENGTH_CONSTRAINTS, 

27 *STRICT, 

28 'strip_whitespace', 

29 'to_lower', 

30 'to_upper', 

31 'pattern', 

32 'coerce_numbers_to_str', 

33 'ascii_only', 

34} 

35BYTES_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT} 

36 

37LIST_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST} 

38TUPLE_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST} 

39SET_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST} 

40DICT_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *STRICT, *FAIL_FAST} 

41GENERATOR_CONSTRAINTS = LENGTH_CONSTRAINTS 

42SEQUENCE_CONSTRAINTS = {*LENGTH_CONSTRAINTS, *FAIL_FAST} 

43 

44FLOAT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *ALLOW_INF_NAN, *STRICT} 

45DECIMAL_CONSTRAINTS = {'max_digits', 'decimal_places', *FLOAT_CONSTRAINTS} 

46FRACTION_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *STRICT} 

47INT_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *STRICT} 

48BOOL_CONSTRAINTS = STRICT 

49UUID_CONSTRAINTS = STRICT 

50 

51DATE_TIME_CONSTRAINTS = {*INEQUALITY, *STRICT} 

52TIMEDELTA_CONSTRAINTS = {*NUMERIC_CONSTRAINTS, *STRICT} 

53LAX_OR_STRICT_CONSTRAINTS = STRICT 

54ENUM_CONSTRAINTS = STRICT 

55COMPLEX_CONSTRAINTS = STRICT 

56 

57UNION_CONSTRAINTS = {'union_mode'} 

58URL_CONSTRAINTS = { 

59 'max_length', 

60 'allowed_schemes', 

61 'host_required', 

62 'default_host', 

63 'default_port', 

64 'default_path', 

65 *STRICT, 

66} 

67 

68CONSTRAINTS_TO_ALLOWED_SCHEMAS: dict[str, set[str]] = defaultdict(set) 

69 

70constraint_schema_pairings: list[tuple[set[str], tuple[str, ...]]] = [ 

71 (STR_CONSTRAINTS, ('str',)), 

72 (BYTES_CONSTRAINTS, ('bytes',)), 

73 (LIST_CONSTRAINTS, ('list', 'deque')), 

74 (TUPLE_CONSTRAINTS, ('tuple',)), 

75 (SET_CONSTRAINTS, ('set', 'frozenset')), 

76 (DICT_CONSTRAINTS, ('dict', 'frozendict', 'ordered-dict', 'counter')), 

77 (GENERATOR_CONSTRAINTS, ('generator',)), 

78 (FLOAT_CONSTRAINTS, ('float',)), 

79 (INT_CONSTRAINTS, ('int',)), 

80 (DATE_TIME_CONSTRAINTS, ('date', 'time', 'datetime')), 

81 (TIMEDELTA_CONSTRAINTS, ('timedelta',)), 

82 (STRICT, ('typed-dict', 'model')), 

83 (UNION_CONSTRAINTS, ('union',)), 

84 (URL_CONSTRAINTS, ('url', 'multi-host-url')), 

85 (BOOL_CONSTRAINTS, ('bool',)), 

86 (UUID_CONSTRAINTS, ('uuid',)), 

87 (LAX_OR_STRICT_CONSTRAINTS, ('lax-or-strict',)), 

88 (ENUM_CONSTRAINTS, ('enum',)), 

89 (DECIMAL_CONSTRAINTS, ('decimal',)), 

90 (COMPLEX_CONSTRAINTS, ('complex',)), 

91 (FRACTION_CONSTRAINTS, ('fraction',)), 

92] 

93 

94for constraints, schemas in constraint_schema_pairings: 

95 for c in constraints: 

96 CONSTRAINTS_TO_ALLOWED_SCHEMAS[c].update(schemas) 

97 

98 

99def as_jsonable_value(v: Any) -> Any: 

100 if type(v) not in (int, str, float, bytes, bool, type(None)): 

101 return to_jsonable_python(v) 

102 return v 

103 

104 

105def _is_grouped_metadata(annotation: Any) -> bool: 

106 """Check whether `annotation` is an instance of the `annotated_types.GroupedMetadata` protocol.""" 

107 # `annotated_types.GroupedMetadata` is a `@runtime_checkable` protocol, and `isinstance()` checks 

108 # are quite slow. Instead, we check against the `__is_annotated_types_grouped_metadata__` property 

109 # (see https://github.com/annotated-types/annotated-types/blob/96eef3cb/annotated_types/__init__.py#L169-L171). 

110 # The `is True` check excludes classes (for which the property object itself is returned): 

111 return getattr(annotation, '__is_annotated_types_grouped_metadata__', False) is True 

112 

113 

114def expand_grouped_metadata(annotations: Iterable[Any]) -> Iterable[Any]: 

115 """Expand the annotations. 

116 

117 Args: 

118 annotations: An iterable of annotations. 

119 

120 Returns: 

121 An iterable of expanded annotations. 

122 

123 Example: 

124 ```python 

125 from annotated_types import Ge, Len 

126 

127 from pydantic._internal._known_annotated_metadata import expand_grouped_metadata 

128 

129 print(list(expand_grouped_metadata([Ge(4), Len(5)]))) 

130 #> [Ge(ge=4), MinLen(min_length=5)] 

131 ``` 

132 """ 

133 FieldInfo = import_cached_field_info() 

134 

135 for annotation in annotations: 

136 if _is_grouped_metadata(annotation): 

137 yield from annotation 

138 elif isinstance(annotation, FieldInfo): 

139 yield from annotation.metadata 

140 # this is a bit problematic in that it results in duplicate metadata 

141 # all of our "consumers" can handle it, but it is not ideal 

142 # we probably should split up FieldInfo into: 

143 # - annotated types metadata 

144 # - individual metadata known only to Pydantic 

145 annotation = copy(annotation) 

146 annotation.metadata = [] 

147 yield annotation 

148 else: 

149 yield annotation 

150 

151 

152@lru_cache 

153def _get_at_to_constraint_map() -> dict[type, str]: 

154 """Return a mapping of annotated types to constraints. 

155 

156 Normally, we would define a mapping like this in the module scope, but we can't do that 

157 because we don't permit module level imports of `annotated_types`, in an attempt to speed up 

158 the import time of `pydantic`. We still only want to have this dictionary defined in one place, 

159 so we use this function to cache the result. 

160 """ 

161 import annotated_types as at 

162 

163 return { 

164 at.Gt: 'gt', 

165 at.Ge: 'ge', 

166 at.Lt: 'lt', 

167 at.Le: 'le', 

168 at.MultipleOf: 'multiple_of', 

169 at.MinLen: 'min_length', 

170 at.MaxLen: 'max_length', 

171 } 

172 

173 

174def apply_known_metadata(annotation: Any, schema: CoreSchema) -> CoreSchema | None: # noqa: C901 

175 """Apply `annotation` to `schema` if it is an annotation we know about (Gt, Le, etc.). 

176 Otherwise return `None`. 

177 

178 This does not handle all known annotations. If / when it does, it can always 

179 return a CoreSchema and return the unmodified schema if the annotation should be ignored. 

180 

181 Assumes that GroupedMetadata has already been expanded via `expand_grouped_metadata`. 

182 

183 Args: 

184 annotation: The annotation. 

185 schema: The schema. 

186 

187 Returns: 

188 An updated schema with annotation if it is an annotation we know about, `None` otherwise. 

189 

190 Raises: 

191 RuntimeError: If a constraint can't be applied to a specific schema type. 

192 ValueError: If an unknown constraint is encountered. 

193 """ 

194 import annotated_types as at 

195 

196 from ._validators import NUMERIC_VALIDATOR_LOOKUP, forbid_inf_nan_check 

197 

198 schema = schema.copy() 

199 schema_update, other_metadata = collect_known_metadata([annotation]) 

200 schema_type = schema['type'] 

201 

202 chain_schema_constraints: set[str] = { 

203 'pattern', 

204 'strip_whitespace', 

205 'to_lower', 

206 'to_upper', 

207 'coerce_numbers_to_str', 

208 'ascii_only', 

209 } 

210 chain_schema_steps: list[CoreSchema] = [] 

211 

212 for constraint, value in schema_update.items(): 

213 if constraint not in CONSTRAINTS_TO_ALLOWED_SCHEMAS: 

214 raise ValueError(f'Unknown constraint {constraint}') 

215 allowed_schemas = CONSTRAINTS_TO_ALLOWED_SCHEMAS[constraint] 

216 

217 # if it becomes necessary to handle more than one constraint 

218 # in this recursive case with function-after or function-wrap, we should refactor 

219 # this is a bit challenging because we sometimes want to apply constraints to the inner schema, 

220 # whereas other times we want to wrap the existing schema with a new one that enforces a new constraint. 

221 if schema_type in {'function-before', 'function-wrap', 'function-after'} and constraint == 'strict': 

222 schema['schema'] = apply_known_metadata(annotation, schema['schema']) # type: ignore # schema is function schema 

223 return schema 

224 

225 # if we're allowed to apply constraint directly to the schema, like le to int, do that 

226 if schema_type in allowed_schemas: 

227 if constraint == 'union_mode' and schema_type == 'union': 

228 schema['mode'] = value # type: ignore # schema is UnionSchema 

229 else: 

230 schema[constraint] = value 

231 continue 

232 

233 # else, apply a function after validator to the schema to enforce the corresponding constraint 

234 if constraint in chain_schema_constraints: 

235 

236 def _apply_constraint_with_incompatibility_info( 

237 value: Any, handler: cs.ValidatorFunctionWrapHandler 

238 ) -> Any: 

239 try: 

240 x = handler(value) 

241 except ValidationError as ve: 

242 # if the error is about the type, it's likely that the constraint is incompatible the type of the field 

243 # for example, the following invalid schema wouldn't be caught during schema build, but rather at this point 

244 # with a cryptic 'string_type' error coming from the string validator, 

245 # that we'd rather express as a constraint incompatibility error (TypeError) 

246 # Annotated[list[int], Field(pattern='abc')] 

247 if 'type' in ve.errors()[0]['type']: 

248 raise TypeError( 

249 f"Unable to apply constraint '{constraint}' to supplied value {value} for schema of type '{schema_type}'" # noqa: B023 

250 ) 

251 raise ve 

252 return x 

253 

254 chain_schema_steps.append( 

255 cs.no_info_wrap_validator_function( 

256 _apply_constraint_with_incompatibility_info, cs.str_schema(**{constraint: value}) 

257 ) 

258 ) 

259 elif constraint in NUMERIC_VALIDATOR_LOOKUP: 

260 if constraint in LENGTH_CONSTRAINTS: 

261 inner_schema = schema 

262 while inner_schema['type'] in {'function-before', 'function-wrap', 'function-after'}: 

263 inner_schema = inner_schema['schema'] # type: ignore 

264 inner_schema_type = inner_schema['type'] 

265 if inner_schema_type == 'list' or ( 

266 inner_schema_type == 'json-or-python' and inner_schema['json_schema']['type'] == 'list' # type: ignore 

267 ): 

268 js_constraint_key = 'minItems' if constraint == 'min_length' else 'maxItems' 

269 else: 

270 js_constraint_key = 'minLength' if constraint == 'min_length' else 'maxLength' 

271 else: 

272 js_constraint_key = constraint 

273 

274 validator_kwargs = {constraint: value} 

275 if constraint == 'decimal_places' and schema_update.get('max_digits') is not None: 

276 # The whole digits limit depends on both constraints, and is checked alongside `decimal_places`: 

277 validator_kwargs['max_digits'] = schema_update['max_digits'] 

278 schema = cs.no_info_after_validator_function( 

279 partial(NUMERIC_VALIDATOR_LOOKUP[constraint], **validator_kwargs), schema 

280 ) 

281 metadata = schema.get('metadata', {}) 

282 if (existing_json_schema_updates := metadata.get('pydantic_js_updates')) is not None: 

283 metadata['pydantic_js_updates'] = { 

284 **existing_json_schema_updates, 

285 **{js_constraint_key: as_jsonable_value(value)}, 

286 } 

287 else: 

288 metadata['pydantic_js_updates'] = {js_constraint_key: as_jsonable_value(value)} 

289 schema['metadata'] = metadata 

290 elif constraint == 'allow_inf_nan' and value is False: 

291 schema = cs.no_info_after_validator_function( 

292 forbid_inf_nan_check, 

293 schema, 

294 ) 

295 else: 

296 # It's rare that we'd get here, but it's possible if we add a new constraint and forget to handle it 

297 # Most constraint errors are caught at runtime during attempted application 

298 raise RuntimeError(f"Unable to apply constraint '{constraint}' to schema of type '{schema_type}'") 

299 

300 for annotation in other_metadata: 

301 if (annotation_type := type(annotation)) in (at_to_constraint_map := _get_at_to_constraint_map()): 

302 constraint = at_to_constraint_map[annotation_type] 

303 validator = NUMERIC_VALIDATOR_LOOKUP.get(constraint) 

304 if validator is None: 

305 raise ValueError(f'Unknown constraint {constraint}') 

306 schema = cs.no_info_after_validator_function( 

307 partial(validator, {constraint: getattr(annotation, constraint)}), schema 

308 ) 

309 continue 

310 elif isinstance(annotation, (at.Predicate, at.Not)): 

311 predicate_name = f'{annotation.func.__qualname__!r} ' if hasattr(annotation.func, '__qualname__') else '' 

312 

313 # Note: B023 is ignored because even though we iterate over `other_metadata`, it is guaranteed 

314 # to be of length 1. `apply_known_metadata()` is called from `GenerateSchema`, where annotations 

315 # were already expanded via `expand_grouped_metadata()`. Confusing, but this falls into the annotations 

316 # refactor. 

317 if isinstance(annotation, at.Predicate): 

318 

319 def val_func(v: Any) -> Any: 

320 predicate_satisfied = annotation.func(v) # noqa: B023 

321 if not predicate_satisfied: 

322 raise PydanticCustomError( 

323 'predicate_failed', 

324 f'Predicate {predicate_name}failed', # pyright: ignore[reportArgumentType] # noqa: B023 

325 ) 

326 return v 

327 

328 else: 

329 

330 def val_func(v: Any) -> Any: 

331 predicate_satisfied = annotation.func(v) # noqa: B023 

332 if predicate_satisfied: 

333 raise PydanticCustomError( 

334 'not_operation_failed', 

335 f'Not of {predicate_name}failed', # pyright: ignore[reportArgumentType] # noqa: B023 

336 ) 

337 return v 

338 

339 schema = cs.no_info_after_validator_function(val_func, schema) 

340 else: 

341 # ignore any other unknown metadata 

342 return None 

343 

344 if chain_schema_steps: 

345 chain_schema_steps = [schema] + chain_schema_steps 

346 return cs.chain_schema(chain_schema_steps) 

347 

348 return schema 

349 

350 

351def collect_known_metadata(annotations: Iterable[Any]) -> tuple[dict[str, Any], list[Any]]: 

352 """Split `annotations` into known metadata and unknown annotations. 

353 

354 Args: 

355 annotations: An iterable of annotations. 

356 

357 Returns: 

358 A tuple contains a dict of known metadata and a list of unknown annotations. 

359 

360 Example: 

361 ```python 

362 from annotated_types import Gt, Len 

363 

364 from pydantic._internal._known_annotated_metadata import collect_known_metadata 

365 

366 print(collect_known_metadata([Gt(1), Len(42), ...])) 

367 #> ({'gt': 1, 'min_length': 42}, [Ellipsis]) 

368 ``` 

369 """ 

370 annotations = expand_grouped_metadata(annotations) 

371 

372 res: dict[str, Any] = {} 

373 remaining: list[Any] = [] 

374 

375 for annotation in annotations: 

376 # isinstance(annotation, PydanticMetadata) also covers ._fields:_PydanticGeneralMetadata 

377 if isinstance(annotation, PydanticMetadata): 

378 res.update(annotation.__dict__) 

379 # we don't use dataclasses.asdict because that recursively calls asdict on the field values 

380 elif (annotation_type := type(annotation)) in (at_to_constraint_map := _get_at_to_constraint_map()): 

381 constraint = at_to_constraint_map[annotation_type] 

382 res[constraint] = getattr(annotation, constraint) 

383 elif isinstance(annotation, type) and issubclass(annotation, PydanticMetadata): 

384 # also support PydanticMetadata classes being used without initialisation, 

385 # e.g. `Annotated[int, Strict]` as well as `Annotated[int, Strict()]` 

386 res.update({k: v for k, v in vars(annotation).items() if not k.startswith('_')}) 

387 else: 

388 remaining.append(annotation) 

389 # Nones can sneak in but pydantic-core will reject them 

390 # it'd be nice to clean things up so we don't put in None (we probably don't _need_ to, it was just easier) 

391 # but this is simple enough to kick that can down the road 

392 res = {k: v for k, v in res.items() if v is not None} 

393 return res, remaining 

394 

395 

396def check_metadata(metadata: dict[str, Any], allowed: Iterable[str], source_type: Any) -> None: 

397 """A small utility function to validate that the given metadata can be applied to the target. 

398 More than saving lines of code, this gives us a consistent error message for all of our internal implementations. 

399 

400 Args: 

401 metadata: A dict of metadata. 

402 allowed: An iterable of allowed metadata. 

403 source_type: The source type. 

404 

405 Raises: 

406 TypeError: If there is metadatas that can't be applied on source type. 

407 """ 

408 unknown = metadata.keys() - set(allowed) 

409 if unknown: 

410 raise TypeError( 

411 f'The following constraints cannot be applied to {source_type!r}: {", ".join([f"{k!r}" for k in unknown])}' 

412 )