Coverage for presidio_analyzer / input_validation / yaml_recognizer_models.py: 95%

198 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-29 09:03 +0000

1"""Pydantic models for YAML recognizer configurations.""" 

2 

3from typing import Any, Dict, List, Optional, Type, Union 

4 

5from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator 

6 

7from presidio_analyzer.input_validation import validate_language_codes 

8from presidio_analyzer.recognizer_registry.recognizers_loader_utils import ( 

9 PredefinedRecognizerNotFoundError, 

10 RecognizerListLoader, 

11) 

12 

13 

14class LanguageContextConfig(BaseModel): 

15 """Configuration for language-specific validation with context words. 

16 

17 :param language: Language code (e.g., 'en', 'es') 

18 :param context: Context words for this language 

19 """ 

20 

21 language: str = Field(..., description="Language code (e.g., 'en', 'es')") 

22 context: Optional[List[str]] = Field( 

23 default=None, description="Context words for this language" 

24 ) 

25 

26 @field_validator("language") 

27 @classmethod 

28 def validate_language_code(cls, v: str) -> str: 

29 """Validate language code format.""" 

30 validate_language_codes([v]) 

31 return v 

32 

33 

34class BaseRecognizerConfig(BaseModel): 

35 """Base validation for all recognizer configuration types. 

36 

37 :param name: Instance name used in analysis results. Defaults to class name. 

38 :param class_name: Python class name for lookup. If not provided, uses 'name'. 

39 :param enabled: Whether the recognizer is enabled 

40 :param type: Type of recognizer (predefined/custom) 

41 :param supported_language: Single supported language (legacy) 

42 :param supported_languages: Multiple supported languages with optional context. 

43 Passing multiple languages will result in multiple actual 

44 recognizers initialized in Presidio. 

45 :param context: context words. Context is best defined 

46 in the language-specific configuration, 

47 as it is language-dependent. If context is defined outside, 

48 it should only work if the user passed one language 

49 (either in supported_language or have a supported_languages with length 1). 

50 :param supported_entity: Supported entity for this recognizer (legacy) 

51 :param supported_entities: List of supported entities for this recognizer. 

52 """ 

53 

54 name: str = Field(..., description="Instance name for the recognizer") 

55 class_name: Optional[str] = Field( 

56 default=None, 

57 description=( 

58 "Python class name for predefined recognizers " 

59 "(if different from instance name)" 

60 ), 

61 ) 

62 enabled: bool = Field(default=True, description="Whether the recognizer is enabled") 

63 type: Optional[str] = Field( 

64 default="predefined", description="Type of recognizer (predefined/custom)" 

65 ) 

66 supported_language: Optional[str] = Field( 

67 default=None, description="The language this recognizer supports" 

68 ) 

69 supported_languages: Optional[Union[List[str], List[LanguageContextConfig]]] = ( 

70 Field( 

71 default=None, 

72 description="Multiple supported languages with optional context", 

73 ) 

74 ) 

75 context: Optional[List[str]] = Field( 

76 default=None, description="Global context words" 

77 ) 

78 supported_entity: Optional[str] = Field( 

79 default=None, description="Supported entity for this recognizer" 

80 ) 

81 supported_entities: Optional[List[str]] = Field( 

82 default=None, description="List of supported entities for this recognizer" 

83 ) 

84 

85 @field_validator("supported_language") 

86 @classmethod 

87 def validate_single_language(cls, v: Optional[str]) -> Optional[str]: 

88 """Validate single language code format.""" 

89 validate_language_codes([v]) 

90 return v 

91 

92 @model_validator(mode="after") 

93 def validate_language_configuration(self): 

94 """Ensure proper language validation.""" 

95 if self.supported_language and self.supported_languages: 

96 raise ValueError( 

97 "Cannot specify both 'supported_language' and 'supported_languages'" 

98 ) 

99 

100 return self 

101 

102 @model_validator(mode="after") 

103 def validate_entity_configuration(self): 

104 """Ensure proper entity validation.""" 

105 # Check if user provided both (before we modify them) 

106 user_provided_both = ( 

107 self.supported_entity is not None and self.supported_entities is not None 

108 ) 

109 

110 if user_provided_both: 

111 raise ValueError( 

112 f"Recognizer {self.name} has both " 

113 "'supported_entity' and 'supported_entities' specified." 

114 ) 

115 

116 return self 

117 

118 @model_validator(mode="after") 

119 def validate_context_configuration(self): 

120 """Validate context configuration according to language settings.""" 

121 # Check if global context is defined 

122 if self.context: 

123 # Global context is only valid if we have exactly one language 

124 if self.supported_languages and len(self.supported_languages) > 1: 

125 raise ValueError( 

126 "Global context can only be used with a single language. " 

127 "For multiple languages, define context in " 

128 "language-specific configurations." 

129 "Example: " 

130 " supported_languages: " 

131 " - language: en " 

132 " context: [credit, card, visa, mastercard] " 

133 " - language: es " 

134 " context: [tarjeta, credito, visa, mastercard] " 

135 ) 

136 return self 

137 

138 

139class PredefinedRecognizerConfig(BaseRecognizerConfig): 

140 """Configuration for predefined recognizers.""" 

141 

142 type: str = Field(default="predefined", description="Type of recognizer") 

143 

144 @model_validator(mode="after") 

145 def validate_predefined_recognizer_exists(self): 

146 """Validate that the predefined recognizer class actually exists.""" 

147 recognizer_class_name = self.class_name if self.class_name else self.name 

148 try: 

149 RecognizerListLoader.get_existing_recognizer_cls(recognizer_class_name) 

150 except PredefinedRecognizerNotFoundError as e: 

151 raise ValueError( 

152 f"Predefined recognizer '{recognizer_class_name}' not found: {str(e)}" 

153 ) from e 

154 return self 

155 

156 

157class HuggingFaceRecognizerConfig(PredefinedRecognizerConfig): 

158 """Configuration specifically for HuggingFace NER models.""" 

159 

160 model_config = ConfigDict(extra="forbid") 

161 

162 model_name: Optional[str] = Field(None, description="HuggingFace model name") 

163 tokenizer_name: Optional[str] = Field( 

164 None, description="HuggingFace tokenizer name" 

165 ) 

166 label_mapping: Optional[Dict[str, str]] = Field(None, description="Label mapping") 

167 threshold: Optional[float] = Field(None, description="Confidence threshold") 

168 aggregation_strategy: Optional[str] = Field( 

169 None, description="Aggregation strategy" 

170 ) 

171 chunk_overlap: Optional[int] = Field(None, description="Chunk overlap") 

172 chunk_size: Optional[int] = Field(None, description="Chunk size") 

173 device: Optional[Union[str, int]] = Field(None, description="Device (cpu/gpu)") 

174 label_prefixes: Optional[List[str]] = Field( 

175 default=None, description="Prefixes to strip from labels (e.g. B-, I-)" 

176 ) 

177 

178 

179class CustomRecognizerConfig(BaseRecognizerConfig): 

180 """Configuration for custom pattern-based recognizers.""" 

181 

182 type: str = Field(default="custom", description="Type of recognizer") 

183 supported_entity: str = Field( 

184 ..., description="Entity type this recognizer detects" 

185 ) 

186 patterns: Optional[List[Dict[str, Any]]] = Field( 

187 default=None, description="List of patterns" 

188 ) 

189 context: Optional[List[str]] = Field( 

190 default=None, description="Global context words" 

191 ) 

192 deny_list: Optional[List[str]] = Field( 

193 default=None, description="Words to deny/exclude" 

194 ) 

195 deny_list_score: Optional[float] = Field( 

196 default=0.0, ge=0.0, le=1.0, description="Deny list score" 

197 ) 

198 

199 # Language validation (legacy and new formats) 

200 supported_language: Optional[str] = Field( 

201 default=None, description="Single supported language (legacy)" 

202 ) 

203 supported_languages: Optional[Union[List[str], List[LanguageContextConfig]]] = ( 

204 Field( 

205 default=None, 

206 description="Multiple supported languages with optional context", 

207 ) 

208 ) 

209 

210 model_config = ConfigDict(arbitrary_types_allowed=True) 

211 

212 @model_validator(mode="before") 

213 @classmethod 

214 def check_predefined_name_conflict(cls, data: Any) -> Any: 

215 """Check if custom recognizer name conflicts with predefined recognizer. 

216 

217 This validation runs BEFORE field validation to provide a clearer error message 

218 when someone tries to use a predefined recognizer name for a custom recognizer. 

219 """ 

220 if isinstance(data, dict): 

221 name = data.get("name") 

222 if name: 

223 try: 

224 RecognizerListLoader.get_existing_recognizer_cls(name) 

225 # If we reach here, the recognizer IS predefined, so raise an error 

226 raise ValueError( 

227 f"Recognizer '{name}' conflicts with a predefined " 

228 f"recognizer. " 

229 f"Custom recognizers cannot use the same name " 

230 f"as predefined recognizers. " 

231 f"Either use type: 'predefined' or choose a different name " 

232 f"for your custom recognizer." 

233 ) 

234 except PredefinedRecognizerNotFoundError: 

235 pass 

236 return data 

237 

238 @field_validator("patterns") 

239 @classmethod 

240 def validate_patterns(cls, patterns: Optional[List[Dict]]) -> Optional[List[Dict]]: 

241 """Validate single language code format. 

242 

243 :param patterns: List of patterns 

244 """ 

245 if patterns and not isinstance(patterns, list): 

246 raise ValueError(f"Patterns should be a list: {patterns}") 

247 

248 for pattern in patterns: 

249 if not isinstance(pattern, dict): 

250 raise ValueError(f"Pattern should be a dict: {pattern}") 

251 if "name" not in pattern: 

252 raise ValueError(f"Pattern should contain a name field: {pattern}") 

253 if "regex" not in pattern: 

254 raise ValueError(f"Pattern should contain a regex field: {pattern}") 

255 if "score" not in pattern: 

256 raise ValueError(f"Pattern should contain a score field: {pattern}") 

257 if not isinstance(pattern["score"], (int, float)): 

258 raise ValueError(f"Pattern score should be a float: {pattern}") 

259 if not (0.0 <= pattern["score"] <= 1.0): 

260 raise ValueError(f"Pattern score should be between 0 and 1: {pattern}") 

261 return patterns 

262 

263 @model_validator(mode="after") 

264 def validate_patterns_or_deny_list(self): 

265 """Ensure custom recognizer has at least patterns or deny_list.""" 

266 if not self.patterns and not self.deny_list: 

267 raise ValueError( 

268 "Custom recognizer must have at least one of 'patterns' or 'deny_list'" 

269 ) 

270 return self 

271 

272 

273class RecognizerRegistryConfig(BaseModel): 

274 """Complete validation for the recognizer registry.""" 

275 

276 supported_languages: Optional[List[str]] = Field( 

277 default=None, description="List of supported languages" 

278 ) 

279 global_regex_flags: int = Field(default=26, description="Global regex flags") 

280 recognizers: List[ 

281 Union[ 

282 HuggingFaceRecognizerConfig, 

283 PredefinedRecognizerConfig, 

284 CustomRecognizerConfig, 

285 str, 

286 ] 

287 ] = Field(default_factory=list, description="List of recognizer configurations") 

288 

289 model_config = ConfigDict(extra="forbid") 

290 

291 @field_validator("supported_languages") 

292 @classmethod 

293 def validate_language_codes( 

294 cls, languages: Optional[List[str]] 

295 ) -> Optional[List[str]]: 

296 """Validate language codes format.""" 

297 

298 # Allow None or empty list for cases where languages will be inferred 

299 if languages is None: 

300 return None 

301 

302 if len(languages) == 0: 

303 return [] 

304 

305 validate_language_codes(languages) 

306 return languages 

307 

308 @model_validator(mode="after") 

309 def validate_languages_for_custom_recognizers(self): 

310 """Validate that custom recognizers have language configuration.""" 

311 # If we have custom recognizers, we need language configuration somewhere 

312 custom_recognizers = [ 

313 rec for rec in self.recognizers if isinstance(rec, CustomRecognizerConfig) 

314 ] 

315 for recognizer in custom_recognizers: 

316 if not recognizer.supported_language and not recognizer.supported_languages: 

317 # If no language config on recognizer, we need global languages 

318 if not self.supported_languages: 

319 raise ValueError( 

320 f"Language configuration missing for custom recognizer " 

321 f"'{recognizer.name}': " 

322 "Either specify 'supported_languages' " 

323 "on the recognizer or provide " 

324 "global 'supported_languages' in the " 

325 "registry configuration." 

326 ) 

327 

328 return self 

329 

330 @model_validator(mode="after") 

331 def validate_recognizers_not_empty(self): 

332 """Ensure recognizers list is not empty after all defaults are applied.""" 

333 if not self.recognizers: 

334 raise ValueError( 

335 "The 'recognizers' field must contain at least one recognizer. " 

336 "Found an empty recognizers list." 

337 ) 

338 return self 

339 

340 @field_validator("recognizers", mode="before") 

341 @classmethod 

342 def parse_recognizers( 

343 cls, recognizers: List[Union[Dict[str, Any], str]] 

344 ) -> List[BaseRecognizerConfig]: 

345 """Parse recognizers from various input formats without duplication.""" 

346 if recognizers is None: 

347 raise ValueError( 

348 "Configuration error: 'recognizers' is required. " 

349 "Please provide a list of recognizers in the configuration." 

350 ) 

351 

352 if not isinstance(recognizers, list): 

353 raise ValueError("Recognizers must be a list") 

354 

355 if len(recognizers) == 0: 

356 raise ValueError( 

357 "The 'recognizers' field must contain at least one recognizer. " 

358 "Found an empty recognizers list." 

359 ) 

360 

361 parsed_recognizers = [] 

362 for recognizer in recognizers: 

363 if isinstance(recognizer, str): 

364 parsed_recognizers.append(recognizer) 

365 continue 

366 

367 if isinstance(recognizer, dict): 

368 recognizer_type = recognizer.get("type") 

369 

370 # Validate conflicting custom-only fields if explicitly predefined 

371 if recognizer_type == "predefined" and ( 

372 "patterns" in recognizer or "deny_list" in recognizer 

373 ): 

374 raise ValueError( 

375 f"Recognizer '{recognizer.get('name')}' is marked " 

376 f"as 'predefined' but contains 'patterns' or 'deny_list' " 

377 f"which are only valid for custom recognizers. " 

378 f"Either use type: 'custom' or remove these fields." 

379 ) 

380 

381 if not recognizer_type: 

382 if "patterns" in recognizer or "deny_list" in recognizer: 

383 recognizer_type = "custom" 

384 recognizer_name = recognizer.get("name") 

385 if recognizer_name: 

386 cls.__check_if_predefined(recognizer_name) 

387 else: 

388 recognizer_type = "predefined" 

389 recognizer["type"] = recognizer_type 

390 

391 if recognizer_type == "predefined": 

392 # Determine config model based on recognizer class_name or name. 

393 recognizer_class_name = recognizer.get("class_name") 

394 recognizer_name = recognizer.get("name") 

395 # Prioritize class_name for lookup 

396 # (e.g., custom instance of HuggingFaceNerRecognizer) 

397 config_model_key = recognizer_class_name or recognizer_name 

398 

399 config_model = CONFIG_MODEL_MAP.get( 

400 config_model_key, PredefinedRecognizerConfig 

401 ) 

402 

403 parsed_recognizers.append(config_model(**recognizer)) 

404 elif recognizer_type == "custom": 

405 parsed_recognizers.append(CustomRecognizerConfig(**recognizer)) 

406 else: 

407 raise ValueError( 

408 f"Invalid recognizer type: {recognizer_type}. " 

409 f"Must be 'predefined' or 'custom'." 

410 ) 

411 continue 

412 

413 parsed_recognizers.append(recognizer) 

414 

415 return parsed_recognizers 

416 

417 @classmethod 

418 def __check_if_predefined(cls, recognizer_name: Optional[Any]) -> None: 

419 try: 

420 RecognizerListLoader.get_existing_recognizer_cls(recognizer_name) 

421 raise ValueError( 

422 f"Recognizer '{recognizer_name}' conflicts with a predefined " 

423 f"recognizer. " 

424 f"Custom recognizers cannot use the same name " 

425 f"as predefined recognizers. " 

426 f"Either use type: 'predefined' or choose a different name " 

427 f"for your custom recognizer." 

428 ) 

429 except PredefinedRecognizerNotFoundError: 

430 pass 

431 

432 @model_validator(mode="after") 

433 def validate_language_presence(self): 

434 """Ensure custom recognizers define languages if no global languages are set.""" 

435 if self.recognizers and ( 

436 not self.supported_languages or len(self.supported_languages) == 0 

437 ): 

438 any_language_defined = False 

439 custom_without_language_present = False 

440 for r in self.recognizers: 

441 if isinstance(r, (PredefinedRecognizerConfig, CustomRecognizerConfig)): 

442 if (r.supported_language and r.supported_language.strip()) or ( 

443 r.supported_languages and len(r.supported_languages) > 0 

444 ): 

445 any_language_defined = True 

446 if ( 

447 isinstance(r, CustomRecognizerConfig) 

448 and not r.supported_language 

449 and not r.supported_languages 

450 ): 

451 custom_without_language_present = True 

452 

453 if custom_without_language_present and not any_language_defined: 

454 raise ValueError( 

455 "Language configuration missing for custom recognizer(s): " 

456 "provide 'supported_languages' at registry level " 

457 "or specify languages for each custom recognizer." 

458 ) 

459 return self 

460 

461 

462# Map specific recognizer classes to their dedicated config models 

463# This allows for modular expansion without polluting the base config 

464CONFIG_MODEL_MAP: Dict[str, Type[BaseModel]] = { 

465 "HuggingFaceNerRecognizer": HuggingFaceRecognizerConfig, 

466}