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 ast
12import hashlib
13import inspect
14import math
15import sys
16from ast import Constant, Expr, NodeVisitor, UnaryOp, USub
17from collections.abc import Iterator, MutableSet
18from functools import lru_cache
19from itertools import chain
20from pathlib import Path
21from types import ModuleType
22from typing import TypeAlias
23
24import hypothesis
25from hypothesis.configuration import storage_directory
26from hypothesis.internal.conjecture.choice import ChoiceTypeT
27from hypothesis.internal.escalation import is_hypothesis_file
28
29ConstantT: TypeAlias = int | float | bytes | str
30
31# unfortunate collision with builtin. I don't want to name the init arg bytes_.
32bytesT = bytes
33
34
35class Constants:
36 def __init__(
37 self,
38 *,
39 integers: MutableSet[int] | None = None,
40 floats: MutableSet[float] | None = None,
41 bytes: MutableSet[bytes] | None = None,
42 strings: MutableSet[str] | None = None,
43 ):
44 self.integers: MutableSet[int] = set() if integers is None else integers
45 self.floats: MutableSet[float] = set() if floats is None else floats
46 self.bytes: MutableSet[bytesT] = set() if bytes is None else bytes
47 self.strings: MutableSet[str] = set() if strings is None else strings
48
49 def set_for_type(
50 self, constant_type: type[ConstantT] | ChoiceTypeT
51 ) -> MutableSet[int] | MutableSet[float] | MutableSet[bytes] | MutableSet[str]:
52 if constant_type is int or constant_type == "integer":
53 return self.integers
54 elif constant_type is float or constant_type == "float":
55 return self.floats
56 elif constant_type is bytes or constant_type == "bytes":
57 return self.bytes
58 elif constant_type is str or constant_type == "string":
59 return self.strings
60 raise ValueError(f"unknown constant_type {constant_type}")
61
62 def add(self, constant: ConstantT) -> None:
63 self.set_for_type(type(constant)).add(constant) # type: ignore
64
65 def __contains__(self, constant: ConstantT) -> bool:
66 return constant in self.set_for_type(type(constant))
67
68 def __or__(self, other: "Constants") -> "Constants":
69 return Constants(
70 integers=self.integers | other.integers, # type: ignore
71 floats=self.floats | other.floats, # type: ignore
72 bytes=self.bytes | other.bytes, # type: ignore
73 strings=self.strings | other.strings, # type: ignore
74 )
75
76 def __iter__(self) -> Iterator[ConstantT]:
77 return iter(chain(self.integers, self.floats, self.bytes, self.strings))
78
79 def __len__(self) -> int:
80 return (
81 len(self.integers) + len(self.floats) + len(self.bytes) + len(self.strings)
82 )
83
84 def __repr__(self) -> str:
85 return f"Constants({self.integers=}, {self.floats=}, {self.bytes=}, {self.strings=})"
86
87 def __eq__(self, other: object) -> bool:
88 if not isinstance(other, Constants):
89 return False
90 return (
91 self.integers == other.integers
92 and self.floats == other.floats
93 and self.bytes == other.bytes
94 and self.strings == other.strings
95 )
96
97
98class TooManyConstants(Exception):
99 # a control flow exception which we raise in ConstantsVisitor when the
100 # number of constants in a module gets too large.
101 pass
102
103
104class ConstantVisitor(NodeVisitor):
105 CONSTANTS_LIMIT: int = 1024
106
107 def __init__(self, *, limit: bool):
108 super().__init__()
109 self.constants = Constants()
110 self.limit = limit
111
112 def _add_constant(self, value: object) -> None:
113 if self.limit and len(self.constants) >= self.CONSTANTS_LIMIT:
114 raise TooManyConstants
115
116 if isinstance(value, str) and (
117 value.isspace()
118 or value == ""
119 # long strings are unlikely to be useful.
120 or len(value) > 20
121 ):
122 return
123 if isinstance(value, bytes) and (
124 value == b""
125 # long bytes seem plausibly more likely to be useful than long strings
126 # (e.g. AES-256 has a 32 byte key), but we still want to cap at some
127 # point to avoid performance issues.
128 or len(value) > 50
129 ):
130 return
131 if isinstance(value, bool):
132 return
133 if isinstance(value, float) and (math.isinf(value) or value == 0.0):
134 # we already upweight inf and ±0.
135 return
136 if isinstance(value, int) and -100 < value < 100:
137 # we already upweight small integers.
138 return
139 if isinstance(value, (int, float, bytes, str)):
140 self.constants.add(value)
141 return
142 if isinstance(value, complex):
143 self._add_constant(value.imag)
144 self._add_constant(value.real)
145 return
146 if value in (None, ...):
147 return
148 # we currently cover all possible cases, but future python versions may introduce
149 # additional cases
150 return # pragma: no cover
151
152 def visit_UnaryOp(self, node: UnaryOp) -> None:
153 # `a = -1` is actually a combination of a USub and the constant 1.
154 if (
155 isinstance(node.op, USub)
156 and isinstance(node.operand, Constant)
157 and isinstance(node.operand.value, (int, float, complex))
158 and not isinstance(node.operand.value, bool)
159 ):
160 self._add_constant(-node.operand.value)
161 # don't recurse on this node to avoid adding the positive variant
162 return
163
164 self.generic_visit(node)
165
166 def visit_Expr(self, node: Expr) -> None:
167 if isinstance(node.value, Constant) and isinstance(node.value.value, str):
168 return
169
170 self.generic_visit(node)
171
172 def visit_JoinedStr(self, node):
173 # dont recurse on JoinedStr, i.e. f strings. Constants that appear *only*
174 # in f strings are unlikely to be helpful.
175 return
176
177 def visit_Constant(self, node):
178 self._add_constant(node.value)
179 self.generic_visit(node)
180
181
182def _constants_from_source(source: str | bytes, *, limit: bool) -> Constants:
183 tree = ast.parse(source)
184 visitor = ConstantVisitor(limit=limit)
185
186 try:
187 visitor.visit(tree)
188 except TooManyConstants:
189 # in the case of an incomplete collection, return nothing, to avoid
190 # muddying caches etc.
191 return Constants()
192
193 return visitor.constants
194
195
196def _constants_file_str(constants: Constants) -> str:
197 return str(sorted(constants, key=lambda v: (str(type(v)), v)))
198
199
200@lru_cache(4096)
201def constants_from_module(module: ModuleType, *, limit: bool = True) -> Constants:
202 try:
203 module_file = inspect.getsourcefile(module)
204 # use type: ignore because we know this might error
205 source_bytes = Path(module_file).read_bytes() # type: ignore
206 except Exception:
207 return Constants()
208
209 if limit and len(source_bytes) > 512 * 1024:
210 # Skip files over 512kb. For reference, the largest source file
211 # in Hypothesis is strategies/_internal/core.py at 107kb at time
212 # of writing.
213 return Constants()
214
215 source_hash = hashlib.sha1(source_bytes).hexdigest()[:16]
216 # separate cache files for each limit param. see discussion in pull/4398
217 cache_dir = storage_directory("constants")
218 cache_p = cache_dir.path / (source_hash + ("" if limit else "_nolimit"))
219 try:
220 return _constants_from_source(cache_p.read_bytes(), limit=limit)
221 except Exception:
222 # if the cached location doesn't exist, or it does exist but there was
223 # a problem reading it, fall back to standard computation of the constants
224 pass
225
226 try:
227 constants = _constants_from_source(source_bytes, limit=limit)
228 except Exception:
229 # A bunch of things can go wrong here.
230 # * ast.parse may fail on the source code
231 # * NodeVisitor may hit a RecursionError (see many related issues on
232 # e.g. libcst https://github.com/Instagram/LibCST/issues?q=recursion),
233 # or a MemoryError (`"[1, " * 200 + "]" * 200`)
234 return Constants()
235
236 try:
237 cache_dir.create_if_missing()
238 cache_p.write_text(
239 f"# file: {module_file}\n# hypothesis_version: {hypothesis.__version__}\n\n"
240 # somewhat arbitrary sort order. The cache file doesn't *have* to be
241 # stable... but it is aesthetically pleasing, and means we could rely
242 # on it in the future!
243 + _constants_file_str(constants),
244 encoding="utf-8",
245 )
246 except Exception: # pragma: no cover
247 pass
248
249 return constants
250
251
252@lru_cache(4096)
253def is_local_module_file(path: str) -> bool:
254 from hypothesis.internal.scrutineer import ModuleLocation
255
256 return (
257 # Skip expensive path lookup for stdlib modules.
258 # This will cause false negatives if a user names their module the
259 # same as a stdlib module.
260 path not in sys.stdlib_module_names
261 # A path containing site-packages is extremely likely to be
262 # ModuleLocation.SITE_PACKAGES. Skip the expensive path lookup here.
263 and "/site-packages/" not in path
264 and ModuleLocation.from_path(path) is ModuleLocation.LOCAL
265 # normally, hypothesis is a third-party library and is not returned
266 # by local_modules. However, if it is installed as an editable package
267 # with pip install -e, then we will pick up on it. Just hardcode an
268 # ignore here.
269 and not is_hypothesis_file(path)
270 # avoid collecting constants from test files
271 and not (
272 "test" in (p := Path(path)).parts
273 or "tests" in p.parts
274 or p.stem.startswith("test_")
275 or p.stem.endswith("_test")
276 )
277 )