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 codecs
12import gzip
13import json
14import os
15import sys
16import tempfile
17import unicodedata
18from collections.abc import Collection, Iterable
19from functools import cache
20from pathlib import Path
21from typing import Literal, TypeAlias
22
23from hypothesis.configuration import storage_directory
24from hypothesis.control import _current_build_context
25from hypothesis.errors import InvalidArgument
26from hypothesis.internal.intervalsets import IntervalSet, IntervalsT
27
28# See https://en.wikipedia.org/wiki/Unicode_character_property#General_Category
29CategoryName: TypeAlias = Literal[
30 "L", # Letter
31 "Lu", # Letter, uppercase
32 "Ll", # Letter, lowercase
33 "Lt", # Letter, titlecase
34 "Lm", # Letter, modifier
35 "Lo", # Letter, other
36 "M", # Mark
37 "Mn", # Mark, nonspacing
38 "Mc", # Mark, spacing combining
39 "Me", # Mark, enclosing
40 "N", # Number
41 "Nd", # Number, decimal digit
42 "Nl", # Number, letter
43 "No", # Number, other
44 "P", # Punctuation
45 "Pc", # Punctuation, connector
46 "Pd", # Punctuation, dash
47 "Ps", # Punctuation, open
48 "Pe", # Punctuation, close
49 "Pi", # Punctuation, initial quote
50 "Pf", # Punctuation, final quote
51 "Po", # Punctuation, other
52 "S", # Symbol
53 "Sm", # Symbol, math
54 "Sc", # Symbol, currency
55 "Sk", # Symbol, modifier
56 "So", # Symbol, other
57 "Z", # Separator
58 "Zs", # Separator, space
59 "Zl", # Separator, line
60 "Zp", # Separator, paragraph
61 "C", # Other
62 "Cc", # Other, control
63 "Cf", # Other, format
64 "Cs", # Other, surrogate
65 "Co", # Other, private use
66 "Cn", # Other, not assigned
67]
68Categories: TypeAlias = Iterable[CategoryName]
69CategoriesTuple: TypeAlias = tuple[CategoryName, ...]
70
71
72def charmap_file(fname: str = "charmap") -> Path:
73 return storage_directory(
74 "unicode_data", unicodedata.unidata_version, f"{fname}.json.gz"
75 ).path
76
77
78_charmap: dict[CategoryName, IntervalsT] | None = None
79
80
81def charmap() -> dict[CategoryName, IntervalsT]:
82 """Return a dict that maps a Unicode category, to a tuple of 2-tuples
83 covering the codepoint intervals for characters in that category.
84
85 >>> charmap()['Co']
86 ((57344, 63743), (983040, 1048573), (1048576, 1114109))
87 """
88 global _charmap
89 # Best-effort caching in the face of missing files and/or unwritable
90 # filesystems is fairly simple: check if loaded, else try loading,
91 # else calculate and try writing the cache.
92 if _charmap is None:
93 f = charmap_file()
94 try:
95 with gzip.GzipFile(f, "rb") as d:
96 tmp_charmap = dict(json.load(d))
97
98 except Exception:
99 # This loop is reduced to using only local variables for performance;
100 # indexing and updating containers is a ~3x slowdown. This doesn't fix
101 # https://github.com/HypothesisWorks/hypothesis/issues/2108 but it helps.
102 category = unicodedata.category # Local variable -> ~20% speedup!
103 tmp_charmap = {}
104 last_cat = category(chr(0))
105 last_start = 0
106 for i in range(1, sys.maxunicode + 1):
107 cat = category(chr(i))
108 if cat != last_cat:
109 tmp_charmap.setdefault(last_cat, []).append((last_start, i - 1))
110 last_cat, last_start = cat, i
111 tmp_charmap.setdefault(last_cat, []).append((last_start, sys.maxunicode))
112
113 try:
114 # Write the Unicode table atomically
115 storage_dir = storage_directory("tmp")
116 storage_dir.create_if_missing()
117 fd, tmpfile = tempfile.mkstemp(dir=storage_dir.path)
118 os.close(fd)
119 # Explicitly set the mtime to get reproducible output
120 with gzip.GzipFile(tmpfile, "wb", mtime=1) as fp:
121 result = json.dumps(sorted(tmp_charmap.items()))
122 fp.write(result.encode())
123
124 os.renames(tmpfile, f)
125 except Exception:
126 pass
127
128 # convert between lists and tuples
129 _charmap = {
130 k: tuple(tuple(pair) for pair in pairs) for k, pairs in tmp_charmap.items()
131 }
132 # each value is a tuple of 2-tuples (that is, tuples of length 2)
133 # and both elements of that tuple are integers.
134 for vs in _charmap.values():
135 ints = list(sum(vs, ()))
136 assert all(isinstance(x, int) for x in ints)
137 assert ints == sorted(ints)
138 assert all(len(tup) == 2 for tup in vs)
139
140 assert _charmap is not None
141 return _charmap
142
143
144@cache
145def intervals_from_codec(
146 codec_name: str,
147) -> tuple[IntervalSet, IntervalSet]: # pragma: no cover
148 """Return IntervalSets of characters which can be encoded with this codec,
149 and the subset which encode successfully but do not decode back to the
150 same character."""
151 assert codec_name == codecs.lookup(codec_name).name
152 fname = charmap_file(f"codec-v2-{codec_name}")
153 try:
154 with gzip.GzipFile(fname) as gzf:
155 encodable_intervals, non_roundtrip_intervals = json.load(gzf)
156
157 except Exception:
158 # This loop is kinda slow, but hopefully we don't need to do it very often!
159 encodable_intervals = []
160 non_roundtrip_intervals = []
161 for i in range(sys.maxunicode + 1):
162 char = chr(i)
163 try:
164 encoded = char.encode(codec_name)
165 except Exception: # usually _but not always_ UnicodeEncodeError
166 continue
167 encodable_intervals.append((i, i))
168 try:
169 # A few legacy codecs have lossy fallback mappings - e.g. under
170 # shift_jis the yen sign encodes to the byte which decodes as a
171 # backslash - so we track the non-round-tripping subset too.
172 roundtrips = encoded.decode(codec_name) == char
173 except Exception:
174 roundtrips = False
175 if not roundtrips:
176 non_roundtrip_intervals.append((i, i))
177
178 res = IntervalSet(encodable_intervals)
179 res = res.union(res)
180 non_roundtrip = IntervalSet(non_roundtrip_intervals)
181 non_roundtrip = non_roundtrip.union(non_roundtrip)
182 try:
183 # Write the Unicode table atomically
184 storage_dir = storage_directory("tmp")
185 storage_dir.create_if_missing()
186 fd, tmpfile = tempfile.mkstemp(dir=storage_dir.path)
187 os.close(fd)
188 # Explicitly set the mtime to get reproducible output
189 with gzip.GzipFile(tmpfile, "wb", mtime=1) as f:
190 f.write(json.dumps([res.intervals, non_roundtrip.intervals]).encode())
191 os.renames(tmpfile, fname)
192 except Exception:
193 pass
194 return res, non_roundtrip
195
196
197_categories: Categories | None = None
198
199
200def categories() -> Categories:
201 """Return a tuple of Unicode categories in a normalised order.
202
203 >>> categories() # doctest: +ELLIPSIS
204 ('Zl', 'Zp', 'Co', 'Me', 'Pc', ..., 'Cc', 'Cs')
205 """
206 global _categories
207 if _categories is None:
208 cm = charmap()
209 categories = sorted(cm.keys(), key=lambda c: len(cm[c]))
210 categories.remove("Cc") # Other, Control
211 categories.remove("Cs") # Other, Surrogate
212 categories.append("Cc")
213 categories.append("Cs")
214 _categories = tuple(categories)
215 return _categories
216
217
218def as_general_categories(cats: Categories, name: str = "cats") -> CategoriesTuple:
219 """Return a tuple of Unicode categories in a normalised order.
220
221 This function expands one-letter designations of a major class to include
222 all subclasses:
223
224 >>> as_general_categories(['N'])
225 ('Nd', 'Nl', 'No')
226
227 See section 4.5 of the Unicode standard for more on classes:
228 https://www.unicode.org/versions/Unicode10.0.0/ch04.pdf
229
230 If the collection ``cats`` includes any elements that do not represent a
231 major class or a class with subclass, a deprecation warning is raised.
232 """
233 major_classes = ("L", "M", "N", "P", "S", "Z", "C")
234 cs = categories()
235 out = set(cats)
236 for c in cats:
237 if c in major_classes:
238 out.discard(c)
239 out.update(x for x in cs if x.startswith(c))
240 elif c not in cs:
241 raise InvalidArgument(
242 f"In {name}={cats!r}, {c!r} is not a valid Unicode category."
243 )
244 return tuple(c for c in cs if c in out)
245
246
247category_index_cache: dict[frozenset[CategoryName], IntervalsT] = {frozenset(): ()}
248
249
250def _category_key(cats: Iterable[str] | None) -> CategoriesTuple:
251 """Return a normalised tuple of all Unicode categories that are in
252 `include`, but not in `exclude`.
253
254 If include is None then default to including all categories.
255 Any item in include that is not a unicode character will be excluded.
256
257 >>> _category_key(exclude=['So'], include=['Lu', 'Me', 'Cs', 'So'])
258 ('Me', 'Lu', 'Cs')
259 """
260 cs = categories()
261 if cats is None:
262 cats = set(cs)
263 return tuple(c for c in cs if c in cats)
264
265
266def _query_for_key(key: Categories) -> IntervalsT:
267 """Return a tuple of codepoint intervals covering characters that match one
268 or more categories in the tuple of categories `key`.
269
270 >>> _query_for_key(categories())
271 ((0, 1114111),)
272 >>> _query_for_key(('Zl', 'Zp', 'Co'))
273 ((8232, 8233), (57344, 63743), (983040, 1048573), (1048576, 1114109))
274 """
275 key = tuple(key)
276 # ignore ordering on the cache key to increase potential cache hits.
277 cache_key = frozenset(key)
278 context = _current_build_context.value
279 if context is None or not context.data.provider.avoid_realization:
280 try:
281 return category_index_cache[cache_key]
282 except KeyError:
283 pass
284 elif not key: # pragma: no cover # only on alternative backends
285 return ()
286 assert key
287 if set(key) == set(categories()):
288 result = IntervalSet([(0, sys.maxunicode)])
289 else:
290 result = IntervalSet(_query_for_key(key[:-1])).union(
291 IntervalSet(charmap()[key[-1]])
292 )
293 assert isinstance(result, IntervalSet)
294 if context is None or not context.data.provider.avoid_realization:
295 category_index_cache[cache_key] = result.intervals
296 return result.intervals
297
298
299limited_category_index_cache: dict[
300 tuple[CategoriesTuple, int, int, IntervalsT, IntervalsT], IntervalSet
301] = {}
302
303
304def query(
305 *,
306 categories: Categories | None = None,
307 min_codepoint: int | None = None,
308 max_codepoint: int | None = None,
309 include_characters: Collection[str] = "",
310 exclude_characters: Collection[str] = "",
311) -> IntervalSet:
312 """Return a tuple of intervals covering the codepoints for all characters
313 that meet the criteria.
314
315 >>> query()
316 ((0, 1114111),)
317 >>> query(min_codepoint=0, max_codepoint=128)
318 ((0, 128),)
319 >>> query(min_codepoint=0, max_codepoint=128, categories=['Lu'])
320 ((65, 90),)
321 >>> query(min_codepoint=0, max_codepoint=128, categories=['Lu'],
322 ... include_characters='☃')
323 ((65, 90), (9731, 9731))
324 """
325 if min_codepoint is None:
326 min_codepoint = 0
327 if max_codepoint is None:
328 max_codepoint = sys.maxunicode
329
330 if min_codepoint > max_codepoint:
331 raise InvalidArgument(
332 f"min_codepoint={min_codepoint} is greater than max_codepoint={max_codepoint}"
333 )
334
335 catkey = _category_key(categories)
336 character_intervals = IntervalSet.from_string("".join(include_characters))
337 exclude_intervals = IntervalSet.from_string("".join(exclude_characters))
338 qkey = (
339 catkey,
340 min_codepoint,
341 max_codepoint,
342 character_intervals.intervals,
343 exclude_intervals.intervals,
344 )
345 context = _current_build_context.value
346 if context is None or not context.data.provider.avoid_realization:
347 try:
348 return limited_category_index_cache[qkey]
349 except KeyError:
350 pass
351
352 result = []
353 for u, v in _query_for_key(catkey):
354 if v >= min_codepoint and u <= max_codepoint:
355 result.append((max(u, min_codepoint), min(v, max_codepoint)))
356
357 result = (IntervalSet(result) | character_intervals) - exclude_intervals
358 if context is None or not context.data.provider.avoid_realization:
359 limited_category_index_cache[qkey] = result
360
361 return result