1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Copyright the Hypothesis Authors.
5# Individual contributors are listed in AUTHORS.rst and the git log.
6#
7# This Source Code Form is subject to the terms of the Mozilla Public License,
8# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9# obtain one at https://mozilla.org/MPL/2.0/.
10
11import copy
12import re
13import warnings
14from collections.abc import Collection
15from functools import cache, lru_cache, partial
16from typing import Any, cast
17
18from hypothesis.errors import (
19 CannotInvert,
20 HypothesisWarning,
21 InvalidArgument,
22 NonRoundTrippableCharactersWarning,
23)
24from hypothesis.internal import charmap
25from hypothesis.internal.charmap import Categories
26from hypothesis.internal.conjecture.choice import ChoiceT
27from hypothesis.internal.conjecture.data import ConjectureData
28from hypothesis.internal.conjecture.providers import COLLECTION_DEFAULT_MAX_SIZE
29from hypothesis.internal.filtering import max_len, min_len
30from hypothesis.internal.intervalsets import IntervalSet
31from hypothesis.internal.reflection import get_pretty_function_description
32from hypothesis.strategies._internal.collections import ListStrategy
33from hypothesis.strategies._internal.lazy import unwrap_strategies
34from hypothesis.strategies._internal.strategies import (
35 OneOfStrategy,
36 SampledFromStrategy,
37 SearchStrategy,
38)
39from hypothesis.vendor.pretty import pretty
40
41
42# Cache size is limited by sys.maxunicode, but passing None makes it slightly faster.
43@cache
44# this is part of our forward-facing validation, so we do *not* tell mypyc that c
45# should be a str, because we don't want it to validate it before we can.
46def _check_is_single_character(c: object) -> str:
47 # In order to mitigate the performance cost of this check, we use a shared cache,
48 # even at the cost of showing the culprit strategy in the error message.
49 if not isinstance(c, str):
50 type_ = get_pretty_function_description(type(c))
51 raise InvalidArgument(f"Got non-string {c!r} (type {type_})")
52 if len(c) != 1:
53 raise InvalidArgument(f"Got {c!r} (length {len(c)} != 1)")
54 return c
55
56
57def _check_alphabet_elements(alphabet: Collection[str]) -> None:
58 non_string = [c for c in alphabet if not isinstance(c, str)]
59 if non_string:
60 raise InvalidArgument(
61 "The following elements in alphabet are not unicode "
62 f"strings: {non_string!r}"
63 )
64 not_one_char = [c for c in alphabet if len(c) != 1]
65 if not_one_char:
66 raise InvalidArgument(
67 "The following elements in alphabet are not of length one, "
68 f"which leads to violation of size constraints: {not_one_char!r}"
69 )
70 if alphabet in ["ascii", "utf-8"]:
71 warnings.warn(
72 f"alphabet={alphabet!r}: it seems like you are trying to use the "
73 f"codec {alphabet!r}, but this generates strings using the literal "
74 f"characters {list(alphabet)!r}. To specify the {alphabet} codec, "
75 f"use alphabet=st.characters(codec={alphabet!r}). If you intended "
76 "to use character literals, you can silence this warning by "
77 "reordering the characters.",
78 HypothesisWarning,
79 # this stacklevel is of course incorrect, but breaking out of the
80 # levels of LazyStrategy and validation isn't worthwhile.
81 stacklevel=1,
82 )
83
84
85class OneCharStringStrategy(SearchStrategy[str]):
86 """A strategy which generates single character strings of text type."""
87
88 def __init__(self, intervals: IntervalSet, force_repr: str | None = None) -> None:
89 super().__init__()
90 assert isinstance(intervals, IntervalSet)
91 self.intervals = intervals
92 self._force_repr = force_repr
93
94 @classmethod
95 def from_characters_args(
96 cls,
97 *,
98 codec: str | None = None,
99 min_codepoint: int | None = None,
100 max_codepoint: int | None = None,
101 categories: Categories | None = None,
102 exclude_characters: Collection[str] = "",
103 include_characters: Collection[str] = "",
104 ) -> "OneCharStringStrategy":
105 assert set(categories or ()).issubset(charmap.categories())
106 intervals = charmap.query(
107 min_codepoint=min_codepoint,
108 max_codepoint=max_codepoint,
109 categories=categories,
110 exclude_characters=exclude_characters,
111 )
112 include_intervals = IntervalSet.from_string("".join(include_characters))
113 if codec is not None:
114 encodable, non_roundtrip = charmap.intervals_from_codec(codec)
115 intervals &= encodable
116 if undecided := (intervals & non_roundtrip) - include_intervals:
117 chars = "".join(map(chr, undecided))
118 # also show the \u-escaped form, in case the raw repr doesn't
119 # display or copy-paste cleanly in the user's terminal
120 aka = "" if ascii(chars) == repr(chars) else f" (aka {chars!a})"
121 warnings.warn(
122 f"Characters {chars!r}{aka} can be encoded with "
123 f"codec={codec!r}, but do not decode back to the same "
124 "character, so strings containing them do not round-trip. "
125 "Pass each of them in either include_characters, to "
126 f"generate them without this warning, or "
127 f"exclude_characters={chars!r}, to generate only "
128 "characters which round-trip.",
129 NonRoundTrippableCharactersWarning,
130 # this stacklevel is of course incorrect, but breaking out
131 # of the levels of LazyStrategy and validation isn't
132 # worthwhile.
133 stacklevel=1,
134 )
135 # include_characters are generated even if excluded by other arguments,
136 # such as the passed categories or codepoint range. (overlap with
137 # exclude_characters raises an error in st.characters())
138 intervals |= include_intervals
139
140 _arg_repr = ", ".join(
141 f"{k}={v!r}"
142 for k, v in [
143 ("codec", codec),
144 ("min_codepoint", min_codepoint),
145 ("max_codepoint", max_codepoint),
146 ("categories", categories),
147 ("exclude_characters", exclude_characters),
148 ("include_characters", include_characters),
149 ]
150 if v not in (None, "")
151 and not (
152 k == "categories"
153 # v has to be `categories` here. Help mypy along to infer that.
154 and set(cast(Categories, v)) == set(charmap.categories()) - {"Cs"}
155 )
156 )
157 if not intervals:
158 raise InvalidArgument(
159 "No characters are allowed to be generated by this "
160 f"combination of arguments: {_arg_repr}"
161 )
162 return cls(intervals, force_repr=f"characters({_arg_repr})")
163
164 @classmethod
165 def from_alphabet(
166 cls, alphabet: Collection[str] | SearchStrategy[str]
167 ) -> "OneCharStringStrategy | None":
168 # Shared logic for the `alphabet=` parameter of st.text and st.from_regex.
169 # Returns None if `alphabet` cannot be statically resolved to a set of characters,
170 # since each caller may wish to handle this case differently.
171 if not isinstance(alphabet, SearchStrategy):
172 _check_alphabet_elements(alphabet)
173 return cls.from_characters_args(categories=(), include_characters=alphabet)
174
175 char_strategy = unwrap_strategies(alphabet)
176 if isinstance(char_strategy, cls):
177 return char_strategy
178 elif isinstance(char_strategy, SampledFromStrategy):
179 if char_strategy._transformations:
180 # resolving from .elements would ignore the .map/.filter calls
181 return None
182 _check_alphabet_elements(char_strategy.elements)
183 return cls.from_characters_args(
184 categories=(),
185 include_characters=char_strategy.elements,
186 )
187 elif isinstance(char_strategy, OneOfStrategy):
188 intervals = IntervalSet()
189 for s in char_strategy.element_strategies:
190 resolved = cls.from_alphabet(s)
191 if resolved is None:
192 return None
193 intervals = intervals.union(resolved.intervals)
194 return cls(intervals, force_repr=repr(alphabet))
195 return None
196
197 def __repr__(self) -> str:
198 return self._force_repr or f"OneCharStringStrategy({self.intervals!r})"
199
200 def do_draw(self, data: ConjectureData) -> str:
201 return data.draw_string(self.intervals, min_size=1, max_size=1)
202
203 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
204 if not isinstance(value, str) or len(value) != 1:
205 raise CannotInvert(f"{value!r} is not a single character")
206 if ord(value) not in self.intervals:
207 raise CannotInvert(f"{value!r} is not in {self.intervals!r}")
208 return (value,)
209
210
211_nonempty_names = (
212 "capitalize",
213 "expandtabs",
214 "join",
215 "lower",
216 "rsplit",
217 "split",
218 "splitlines",
219 "swapcase",
220 "title",
221 "upper",
222)
223_nonempty_and_content_names = (
224 "islower",
225 "isupper",
226 "isalnum",
227 "isalpha",
228 "isascii",
229 "isdigit",
230 "isspace",
231 "istitle",
232 "lstrip",
233 "rstrip",
234 "strip",
235)
236
237
238class TextStrategy(ListStrategy[str]):
239 def do_draw(self, data):
240 # if our element strategy is OneCharStringStrategy, we can skip the
241 # ListStrategy draw and jump right to data.draw_string.
242 # Doing so for user-provided element strategies is not correct in
243 # general, as they may define a different distribution than data.draw_string.
244 elems = unwrap_strategies(self.element_strategy)
245 if isinstance(elems, OneCharStringStrategy):
246 return data.draw_string(
247 elems.intervals,
248 min_size=self.min_size,
249 max_size=(
250 COLLECTION_DEFAULT_MAX_SIZE
251 if self.max_size == float("inf")
252 else self.max_size
253 ),
254 )
255 return "".join(super().do_draw(data))
256
257 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
258 if not isinstance(value, str):
259 raise CannotInvert(f"{value!r} is not a string")
260 elems = unwrap_strategies(self.element_strategy)
261 if not isinstance(elems, OneCharStringStrategy):
262 # a non-standard element strategy is drawn one character at a time
263 return ListStrategy._invert(self, list(value))
264 effective_max = (
265 COLLECTION_DEFAULT_MAX_SIZE
266 if self.max_size == float("inf")
267 else self.max_size
268 )
269 if not (self.min_size <= len(value) <= effective_max):
270 raise CannotInvert(
271 f"len({value!r})={len(value)} outside "
272 f"[{self.min_size}, {effective_max}]"
273 )
274 if any(ord(c) not in elems.intervals for c in value):
275 raise CannotInvert(f"{value!r} contains chars outside {elems!r}")
276 return (value,)
277
278 def __repr__(self) -> str:
279 args = []
280 if repr(self.element_strategy) != "characters()":
281 args.append(repr(self.element_strategy))
282 if self.min_size:
283 args.append(f"min_size={self.min_size}")
284 if self.max_size < float("inf"):
285 args.append(f"max_size={self.max_size}")
286 return f"text({', '.join(args)})"
287
288 # See https://docs.python.org/3/library/stdtypes.html#string-methods
289 # These methods always return Truthy values for any nonempty string.
290 _nonempty_filters = (
291 *ListStrategy._nonempty_filters,
292 str,
293 str.casefold,
294 str.encode,
295 *(getattr(str, n) for n in _nonempty_names),
296 )
297 _nonempty_and_content_filters = (
298 str.isdecimal,
299 str.isnumeric,
300 *(getattr(str, n) for n in _nonempty_and_content_names),
301 )
302
303 def filter(self, condition):
304 elems = unwrap_strategies(self.element_strategy)
305 if (
306 condition is str.isidentifier
307 and self.max_size >= 1
308 and isinstance(elems, OneCharStringStrategy)
309 ):
310 from hypothesis.strategies import builds, nothing
311
312 id_start, id_continue = _identifier_characters()
313 if not (elems.intervals & id_start):
314 return nothing()
315 return builds(
316 "{}{}".format,
317 OneCharStringStrategy(elems.intervals & id_start),
318 TextStrategy(
319 OneCharStringStrategy(elems.intervals & id_continue),
320 min_size=max(0, self.min_size - 1),
321 max_size=self.max_size - 1,
322 ),
323 # Filter to ensure that NFKC normalization keeps working in future
324 ).filter(str.isidentifier)
325 if (new := _string_filter_rewrite(self, str, condition)) is not None:
326 return new
327 return super().filter(condition)
328
329
330def _string_filter_rewrite(self, kind, condition):
331 if condition in (kind.lower, kind.title, kind.upper):
332 k = kind.__name__
333 warnings.warn(
334 f"You applied {k}.{condition.__name__} as a filter, but this allows "
335 f"all nonempty strings! Did you mean {k}.is{condition.__name__}?",
336 HypothesisWarning,
337 stacklevel=2,
338 )
339
340 if (
341 (
342 kind is bytes
343 or isinstance(
344 unwrap_strategies(self.element_strategy), OneCharStringStrategy
345 )
346 )
347 and isinstance(pattern := getattr(condition, "__self__", None), re.Pattern)
348 and isinstance(pattern.pattern, kind)
349 ):
350 from hypothesis.strategies._internal.regex import regex_strategy
351
352 if condition.__name__ == "match":
353 # Replace with an easier-to-handle equivalent condition
354 caret, close = ("^(?:", ")") if kind is str else (b"^(?:", b")")
355 pattern = re.compile(caret + pattern.pattern + close, flags=pattern.flags)
356 condition = pattern.search
357
358 if condition.__name__ in ("search", "findall", "fullmatch"):
359 s = regex_strategy(
360 pattern,
361 fullmatch=condition.__name__ == "fullmatch",
362 alphabet=self.element_strategy if kind is str else None,
363 )
364 if self.min_size > 0:
365 s = s.filter(partial(min_len, self.min_size))
366 if self.max_size < 1e999:
367 s = s.filter(partial(max_len, self.max_size))
368 return s
369 elif condition.__name__ in ("finditer", "scanner"):
370 # PyPy implements `finditer` as an alias to their `scanner` method
371 warnings.warn(
372 f"You applied {pretty(condition)} as a filter, but this allows "
373 f"any string at all! Did you mean .findall ?",
374 HypothesisWarning,
375 stacklevel=3,
376 )
377 return self
378 elif condition.__name__ == "split":
379 warnings.warn(
380 f"You applied {pretty(condition)} as a filter, but this allows "
381 f"any nonempty string! Did you mean .search ?",
382 HypothesisWarning,
383 stacklevel=3,
384 )
385 return self.filter(bool)
386
387 # We use ListStrategy filter logic for the conditions that *only* imply
388 # the string is nonempty. Here, we increment the min_size but still apply
389 # the filter for conditions that imply nonempty *and specific contents*.
390 if condition in self._nonempty_and_content_filters and self.max_size >= 1:
391 self = copy.copy(self)
392 self.min_size = max(1, self.min_size)
393 return ListStrategy.filter(self, condition)
394
395 return None
396
397
398# Excerpted from https://www.unicode.org/Public/15.0.0/ucd/PropList.txt
399# Python updates it's Unicode version between minor releases, but fortunately
400# these properties do not change between the Unicode versions in question.
401_PROPLIST = """
402# ================================================
403
4041885..1886 ; Other_ID_Start # Mn [2] MONGOLIAN LETTER ALI GALI BALUDA..MONGOLIAN LETTER ALI GALI THREE BALUDA
4052118 ; Other_ID_Start # Sm SCRIPT CAPITAL P
406212E ; Other_ID_Start # So ESTIMATED SYMBOL
407309B..309C ; Other_ID_Start # Sk [2] KATAKANA-HIRAGANA VOICED SOUND MARK..KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK
408
409# Total code points: 6
410
411# ================================================
412
41300B7 ; Other_ID_Continue # Po MIDDLE DOT
4140387 ; Other_ID_Continue # Po GREEK ANO TELEIA
4151369..1371 ; Other_ID_Continue # No [9] ETHIOPIC DIGIT ONE..ETHIOPIC DIGIT NINE
41619DA ; Other_ID_Continue # No NEW TAI LUE THAM DIGIT ONE
417
418# Total code points: 12
419"""
420
421
422@lru_cache
423def _identifier_characters() -> tuple[IntervalSet, IntervalSet]:
424 """See https://docs.python.org/3/reference/lexical_analysis.html#identifiers"""
425 # Start by computing the set of special characters
426 chars = {"Other_ID_Start": "", "Other_ID_Continue": ""}
427 for line in _PROPLIST.splitlines():
428 if m := re.match(r"([0-9A-F.]+) +; (\w+) # ", line):
429 codes, prop = m.groups()
430 span = range(int(codes[:4], base=16), int(codes[-4:], base=16) + 1)
431 chars[prop] += "".join(chr(x) for x in span)
432
433 # Then get the basic set by Unicode category and known extras
434 id_start = charmap.query(
435 categories=("Lu", "Ll", "Lt", "Lm", "Lo", "Nl"),
436 include_characters="_" + chars["Other_ID_Start"],
437 )
438 id_start -= IntervalSet.from_string(
439 # Magic value: the characters which NFKC-normalize to be invalid identifiers.
440 # Conveniently they're all in `id_start`, so we only need to do this once.
441 "\u037a\u0e33\u0eb3\u2e2f\u309b\u309c\ufc5e\ufc5f\ufc60\ufc61\ufc62\ufc63"
442 "\ufdfa\ufdfb\ufe70\ufe72\ufe74\ufe76\ufe78\ufe7a\ufe7c\ufe7e\uff9e\uff9f"
443 )
444 id_continue = id_start | charmap.query(
445 categories=("Mn", "Mc", "Nd", "Pc"),
446 include_characters=chars["Other_ID_Continue"],
447 )
448 return id_start, id_continue
449
450
451class BytesStrategy(SearchStrategy):
452 def __init__(self, min_size: int, max_size: int | None):
453 super().__init__()
454 self.min_size = min_size
455 self.max_size = (
456 max_size if max_size is not None else COLLECTION_DEFAULT_MAX_SIZE
457 )
458
459 def do_draw(self, data: ConjectureData) -> bytes:
460 return data.draw_bytes(self.min_size, self.max_size)
461
462 def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
463 if not isinstance(value, bytes):
464 raise CannotInvert(f"{value!r} is not bytes")
465 if not (self.min_size <= len(value) <= self.max_size):
466 raise CannotInvert(
467 f"len({value!r})={len(value)} outside "
468 f"[{self.min_size}, {self.max_size}]"
469 )
470 return (value,)
471
472 _nonempty_filters = (
473 *ListStrategy._nonempty_filters,
474 bytes,
475 *(getattr(bytes, n) for n in _nonempty_names),
476 )
477 _nonempty_and_content_filters = (
478 *(getattr(bytes, n) for n in _nonempty_and_content_names),
479 )
480
481 def filter(self, condition):
482 if (new := _string_filter_rewrite(self, bytes, condition)) is not None:
483 return new
484 return ListStrategy.filter(self, condition)