Coverage for documents/search/_errors.py: 44%

28 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 09:07 +0000

1from __future__ import annotations 

2 

3from typing import TYPE_CHECKING 

4 

5if TYPE_CHECKING: 5 ↛ 6line 5 didn't jump to line 6 because the condition on line 5 was never true

6 from collections.abc import Sequence 

7 

8 

9class SearchQueryError(ValueError): 

10 """ 

11 Base for user-fixable search query errors. 

12 

13 Carries a message safe to surface to the user (no internal details). The 

14 view layer catches this and returns an HTTP 400, so any future subclass 

15 gets the same treatment. 

16 """ 

17 

18 

19class InvalidDateQuery(SearchQueryError): 

20 """Raised when a date field value or range bound cannot be parsed.""" 

21 

22 def __init__(self, field: str | None, value: str | None) -> None: 

23 self.field = field 

24 self.value = value 

25 super().__init__(f"Invalid date value {value!r} for field {field!r}.") 

26 

27 

28class InvalidNumberQuery(SearchQueryError): 

29 """Raised when a numeric field value or range bound cannot be parsed.""" 

30 

31 def __init__(self, field: str | None, value: str | None) -> None: 

32 self.field = field 

33 self.value = value 

34 super().__init__(f"Invalid numeric value {value!r} for field {field!r}.") 

35 

36 

37class QueryTooLongError(SearchQueryError): 

38 """Raised when a query string exceeds the maximum allowed length. 

39 

40 whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an 

41 unbounded query is a CPU-exhaustion vector against a single request 

42 handler. This is a hard boundary, not a validation nicety. 

43 """ 

44 

45 def __init__(self, length: int, limit: int) -> None: 

46 self.length = length 

47 self.limit = limit 

48 super().__init__( 

49 f"The search query is too long ({length} characters). " 

50 f"The maximum allowed length is {limit} characters.", 

51 ) 

52 

53 

54class MultipleSearchQueryErrors(SearchQueryError): 

55 """Aggregates every user-fixable error from one parse, not just the first.""" 

56 

57 def __init__(self, errors: Sequence[SearchQueryError]) -> None: 

58 self.errors = tuple(errors) 

59 super().__init__("; ".join(str(e) for e in self.errors)) 

60 

61 

62def search_query_error_messages(e: SearchQueryError) -> list[str]: 

63 """The user-facing message list for a SearchQueryError. 

64 

65 Every offending value's message, not just the first, so the user can 

66 fix them all in one round-trip. Shared by every view that maps 

67 SearchQueryError to an HTTP 400. 

68 """ 

69 if isinstance(e, MultipleSearchQueryErrors): 

70 return [str(sub) for sub in e.errors] 

71 return [str(e)]