numberdb 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
numberdb/__init__.py ADDED
@@ -0,0 +1,647 @@
1
+ """NumberDB — look a number up and find out whether it is already known.
2
+
3
+ >>> import numberdb
4
+ >>> for result in numberdb.search('pi'):
5
+ ... print(result.exact_text, '--', result.table.title)
6
+
7
+ Works in plain Python. Inside SageMath, ``result.sage()`` gives the number as a
8
+ Sage object; nothing else needs Sage, and it is not imported until asked for.
9
+
10
+ Anonymous use is rate limited. A key raises the limit:
11
+
12
+ $ export NUMBERDB_API_KEY=...
13
+
14
+ or, if you must set it in code, ``numberdb.configure(api_key='...')``. For more
15
+ than one server or key in a process, use ``Client`` directly.
16
+
17
+ Why a package rather than a file to copy: the response has to be turned into
18
+ numbers, and doing that by hand is how the previous example client came to call
19
+ ``loads()`` on server-supplied bytes -- which runs whatever those bytes say.
20
+ Here decoding is a fixed table (see ``_wire``), and it is versioned, so a change
21
+ to the format is a version bump and a clear message rather than an exception in
22
+ the middle of your session.
23
+ """
24
+
25
+ import json
26
+ from fractions import Fraction
27
+ from typing import Any, Dict, List, Optional, Union
28
+
29
+ from ._convert import Scalar, SupportsParent, to_exact
30
+ from ._limits import (MAX_BATCH, SIGNIFICANT_DIGITS, bound_interval,
31
+ p_adic_digits)
32
+ from ._errors import (NumberDBError, RateLimited, TransportError,
33
+ Unauthorized, UnsupportedNumber)
34
+ from ._http import Client
35
+ from ._wire import (KINDS, ComplexInterval, PAdic, Polynomial, RealInterval,
36
+ decode, to_sage)
37
+
38
+ __all__ = ['search', 'search_many', 'search_text',
39
+ 'search_by_expression',
40
+ 'search_integer', 'search_rational',
41
+ 'search_real_interval', 'search_real_ball',
42
+ 'search_complex_interval', 'search_complex_ball',
43
+ 'search_p_adic', 'search_polynomial',
44
+ 'table', 'tag', 'configure', 'Client',
45
+ 'Result', 'Table', 'SearchResults',
46
+ 'RealInterval', 'ComplexInterval', 'PAdic', 'Polynomial', 'KINDS',
47
+ 'NumberDBError', 'TransportError', 'RateLimited', 'Unauthorized',
48
+ 'UnsupportedNumber', '__version__']
49
+
50
+ try:
51
+ from importlib.metadata import PackageNotFoundError, version
52
+ #Single source of truth: the installed metadata, which comes from
53
+ #pyproject.toml. Declaring the version in two places guarantees they drift.
54
+ __version__ = version('numberdb')
55
+ except Exception: # pragma: no cover - running from a source tree
56
+ __version__ = '0.0.0+unknown'
57
+
58
+ #: Polynomials longer than this are looked up by a digest of their canonical
59
+ #: key rather than sent whole. Comfortably under the 8k a URL survives.
60
+ _HASH_ABOVE = 1500
61
+
62
+ _default_client = Client()
63
+
64
+
65
+ def configure(api_key: Optional[str] = None,
66
+ base_url: Optional[str] = None,
67
+ timeout: Optional[float] = None) -> Client:
68
+ """Set what the module-level functions use.
69
+
70
+ For a single key in a single process. Anything more -- two servers, two
71
+ keys, threads with different credentials -- wants ``Client`` instead.
72
+ """
73
+ global _default_client
74
+ _default_client = Client(api_key=api_key, base_url=base_url,
75
+ timeout=timeout)
76
+ return _default_client
77
+
78
+
79
+ class Table:
80
+ """The table a number was found in."""
81
+
82
+ __slots__ = ('tid', 'title', 'url')
83
+
84
+ def __init__(self, record: Optional[Dict[str, Any]]) -> None:
85
+ record = record or {}
86
+ self.tid = record.get('tid')
87
+ self.title = record.get('title')
88
+ self.url = record.get('url')
89
+
90
+ def __repr__(self):
91
+ return 'Table(%r, %r)' % (self.tid, self.title)
92
+
93
+
94
+ class Result:
95
+ """One number, and where it lives.
96
+
97
+ ``exact_text`` is how the database writes the number: the form to quote in
98
+ a paper or paste back into a search. It is plain text and needs no decoding,
99
+ so it is available whatever this version of the package understands.
100
+
101
+ ``value`` is the number as a Python object -- ``int``, ``Fraction``,
102
+ ``RealInterval``, ``ComplexInterval``, ``PAdic`` or ``Polynomial``. It is
103
+ decoded when first asked for, not when the result arrives. That matters for
104
+ longevity: when the server learns a new kind of number, an older package
105
+ still returns every result, and only the one value it cannot read raises,
106
+ at the point you ask for it. Decoding eagerly would let one unfamiliar
107
+ number throw away an entire search.
108
+ """
109
+
110
+ __slots__ = ('exact_text', 'str_short', 'param', 'table', 'kind',
111
+ '_wire', '_value', '_decoded', '_as_sage')
112
+
113
+ def __init__(self, record: Dict[str, Any],
114
+ as_sage: bool = False) -> None:
115
+ number = record.get('number') or {}
116
+ self._wire = number.get('number')
117
+ self._value = None
118
+ self._decoded = False
119
+ #Set by numberdb.sage, so that a Sage session gets Sage objects without
120
+ #a flag at every call site.
121
+ self._as_sage = as_sage
122
+ self.kind = (self._wire or {}).get('kind')
123
+ self.exact_text = number.get('exact_text') or ''
124
+ self.str_short = number.get('str_short') or ''
125
+ self.param = number.get('param') or ''
126
+ self.table = Table(record.get('table'))
127
+
128
+ @property
129
+ def value(self) -> Any:
130
+ """The number. Raises ``UnsupportedNumber`` if this version cannot
131
+ read its kind -- ``exact_text`` still holds it either way."""
132
+ if not self._decoded:
133
+ value = decode(self._wire) if self._wire else None
134
+ self._value = to_sage(value) if (self._as_sage and value is not None) else value
135
+ self._decoded = True
136
+ return self._value
137
+
138
+ @property
139
+ def is_readable(self) -> bool:
140
+ """Whether ``value`` will decode, without having to try it."""
141
+ return self.kind in KINDS
142
+
143
+ def sage(self) -> Any:
144
+ """The number as a Sage object. Requires SageMath.
145
+
146
+ A conversion, not the stored value: a ball comes back as an interval,
147
+ and an endpoint Sage cannot represent exactly is widened to one it can.
148
+ It always contains the stored number -- verified across the database --
149
+ but it is a faithful container, not a byte-identical round trip.
150
+ """
151
+ if self._as_sage:
152
+ return self.value
153
+ return to_sage(self.value)
154
+
155
+ def url(self) -> Optional[str]:
156
+ """Where to read about it on the site."""
157
+ if not self.table.url:
158
+ return None
159
+ import urllib.parse
160
+ page = urllib.parse.urljoin(_default_client.base_url, self.table.url)
161
+ return '%s#%s' % (page, self.param) if self.param else page
162
+
163
+ def __repr__(self):
164
+ return 'Result(%r, table=%r)' % (self.exact_text or self.str_short,
165
+ self.table.title)
166
+
167
+
168
+ class SearchResults(list):
169
+ """The results, plus anything the server said about the search.
170
+
171
+ A list, so it can simply be iterated. ``messages`` holds notes -- that the
172
+ results were capped, that part of the expression was rejected -- as plain
173
+ strings, kept rather than printed: the caller decides how to report them.
174
+ """
175
+
176
+ def __init__(self, results: List['Result'],
177
+ messages: List[str]) -> None:
178
+ super().__init__(results)
179
+ self.messages = messages
180
+
181
+ @property
182
+ def unreadable(self) -> List['Result']:
183
+ """Results this version cannot decode, if the server is newer."""
184
+ return [result for result in self if not result.is_readable]
185
+
186
+
187
+ def table(table_id: Union[int, str],
188
+ client: Optional[Client] = None) -> Dict[str, Any]:
189
+ """A whole table, as stored. ``table_id`` may be 12 or ``'T12'``.
190
+
191
+ Returned as the server sends it, a plain dict. Deliberately not wrapped in
192
+ classes: a table's shape is the data format's business, and mirroring it
193
+ here would mean this package needed a release every time a table gained a
194
+ field.
195
+ """
196
+ return (client or _default_client).request('api/table', {'id': table_id})
197
+
198
+
199
+ def tag(name: str, client: Optional[Client] = None) -> Dict[str, Any]:
200
+ """The tables carrying a tag. A plain dict, as for ``table``."""
201
+ return (client or _default_client).request('api/tag', {'url': name})
202
+
203
+
204
+ def _lookup(parameters: Dict[str, Any],
205
+ client: Optional[Client]) -> 'SearchResults':
206
+ used = client or _default_client
207
+ payload = used.request('api/lookup', parameters)
208
+ records = payload.get('results') or []
209
+ messages = [message.get('text', '') for message in
210
+ (payload.get('messages') or []) if isinstance(message, dict)]
211
+ return SearchResults([Result(record, used.as_sage)
212
+ for record in records], messages)
213
+
214
+
215
+ def _by_number(record: Dict[str, Any],
216
+ client: Optional[Client]) -> 'SearchResults':
217
+ return _lookup({'number': json.dumps(record)}, client)
218
+
219
+
220
+ def _overlaps(value, low, high) -> bool:
221
+ """Whether a returned value could still be the number originally asked for.
222
+
223
+ A widened query is sound -- it cannot miss -- but it can bring back numbers
224
+ that only matched the widening. The check is filter-and-refine: the coarse
225
+ interval goes to the server, and the exact one is applied here, where the
226
+ original bounds are still known.
227
+
228
+ A value this version cannot decode is kept. It might be the answer, and
229
+ dropping something unexamined is worse than showing it.
230
+ """
231
+ if isinstance(value, RealInterval):
232
+ return value.lower <= high and value.upper >= low
233
+ if isinstance(value, (int, Fraction)):
234
+ return low <= Fraction(value) <= high
235
+ return True
236
+
237
+
238
+ def _refine(results: 'SearchResults', low, high) -> 'SearchResults':
239
+ """Drop results that only matched the widened query."""
240
+ kept = []
241
+ for result in results:
242
+ if not result.is_readable:
243
+ kept.append(result)
244
+ continue
245
+ try:
246
+ if _overlaps(result.value, low, high):
247
+ kept.append(result)
248
+ except UnsupportedNumber:
249
+ kept.append(result)
250
+ return SearchResults(kept, results.messages)
251
+
252
+
253
+ def search_integer(value: Scalar, client: Optional[Client] = None) -> 'SearchResults':
254
+ """Search for an exact integer.
255
+
256
+ The server searches an exact value as a point interval on the real line, so
257
+ the result is what search_real_interval(n, n) would give. This exists to
258
+ say what you mean, and to refuse a value that is not an integer, rather
259
+ than because it asks a mechanically different question.
260
+ """
261
+ exact = to_exact(value, 'value')
262
+ if exact.denominator != 1:
263
+ raise ValueError('%s is not an integer; use search_rational' % (exact,))
264
+ if len(str(abs(exact.numerator))) > SIGNIFICANT_DIGITS:
265
+ #Too long to send exactly, so sent as the range it lies in. The server
266
+ #searches an exact value as a point interval anyway, so this loses
267
+ #nothing that was being used.
268
+ return search_real_interval(exact, exact, client=client)
269
+ return _by_number({'kind': 'ZZ', 'value': str(exact.numerator)},
270
+ client)
271
+
272
+
273
+ def search_rational(numerator: Scalar, denominator: Scalar = 1,
274
+ client: Optional[Client] = None) -> 'SearchResults':
275
+ """Search for an exact rational ``numerator / denominator``.
276
+
277
+ The denominator defaults to 1, so a Fraction can be passed on its own.
278
+ """
279
+ exact = to_exact(numerator, 'numerator') / to_exact(denominator,
280
+ 'denominator')
281
+ if len(str(exact)) > 2 * SIGNIFICANT_DIGITS:
282
+ return search_real_interval(exact, exact, client=client)
283
+ return _by_number({'kind': 'QQ', 'value': str(exact)}, client)
284
+
285
+
286
+ def search_real_interval(lower: Scalar, upper: Scalar,
287
+ client: Optional[Client] = None) -> 'SearchResults':
288
+ """Search for a real known to lie between ``lower`` and ``upper``.
289
+
290
+ Endpoints are converted exactly before anything else touches them, so the
291
+ interval searched is the interval given -- never a rounding of it.
292
+ """
293
+ exact_low, exact_high = to_exact(lower, 'lower'), to_exact(upper, 'upper')
294
+ if exact_low > exact_high:
295
+ exact_low, exact_high = exact_high, exact_low
296
+
297
+ #Trimmed outward, so the interval sent contains the one meant. Trimming
298
+ #inward would hide the number the caller is looking for.
299
+ low, high = bound_interval(exact_low, exact_high)
300
+
301
+ found = _by_number({'kind': 'RIF', 'lower': str(low), 'upper': str(high)},
302
+ client)
303
+ if (low, high) == (exact_low, exact_high):
304
+ return found
305
+ #Widened, so some of what came back may only have matched the widening.
306
+ return _refine(found, exact_low, exact_high)
307
+
308
+
309
+ def search_real_ball(center: Scalar, radius: Scalar,
310
+ client: Optional[Client] = None) -> 'SearchResults':
311
+ """Search for a real known as ``center`` give or take ``radius``.
312
+
313
+ The form to use for an experimental value: state the uncertainty you
314
+ actually have, rather than letting the digits of a float imply one.
315
+ """
316
+ middle, spread = to_exact(center, 'center'), abs(to_exact(radius, 'radius'))
317
+ return search_real_interval(middle - spread, middle + spread,
318
+ client=client)
319
+
320
+
321
+ def search_complex_interval(re_lower: Scalar, re_upper: Scalar,
322
+ im_lower: Scalar, im_upper: Scalar,
323
+ client: Optional[Client] = None) -> 'SearchResults':
324
+ """Search for a complex number known to lie in a rectangle."""
325
+ #Each coordinate bounded on its own, so a large real part cannot cost the
326
+ #imaginary one its precision.
327
+ real = list(bound_interval(to_exact(re_lower, 're_lower'),
328
+ to_exact(re_upper, 're_upper')))
329
+ imaginary = list(bound_interval(to_exact(im_lower, 'im_lower'),
330
+ to_exact(im_upper, 'im_upper')))
331
+ return _by_number({'kind': 'CIF',
332
+ 're_lower': str(real[0]), 're_upper': str(real[1]),
333
+ 'im_lower': str(imaginary[0]),
334
+ 'im_upper': str(imaginary[1])}, client)
335
+
336
+
337
+ def search_complex_ball(re_center: Scalar, im_center: Scalar, radius: Scalar,
338
+ client: Optional[Client] = None) -> 'SearchResults':
339
+ """Search for a complex number known to within ``radius`` of a centre.
340
+
341
+ The disc is widened to the square that contains it: the database stores
342
+ rectangles, and widening is the direction that cannot lose a match.
343
+ """
344
+ spread = abs(to_exact(radius, 'radius'))
345
+ real = to_exact(re_center, 're_center')
346
+ imaginary = to_exact(im_center, 'im_center')
347
+ return search_complex_interval(real - spread, real + spread,
348
+ imaginary - spread, imaginary + spread,
349
+ client=client)
350
+
351
+
352
+ def search_p_adic(prime: int, order: int, unit: int,
353
+ absolute_precision: Optional[int] = None,
354
+ relative_precision: Optional[int] = None,
355
+ client: Optional[Client] = None) -> 'SearchResults':
356
+ """Search for ``prime**order * unit``, known to the given precision.
357
+
358
+ ``unit`` must be coprime to ``prime``. Exactly one precision must be
359
+ given, and it must be named: absolute and relative coincide at order zero
360
+ and diverge silently elsewhere, so a bare number would have to be
361
+ remembered rather than read.
362
+ """
363
+ if (absolute_precision is None) == (relative_precision is None):
364
+ raise TypeError('give exactly one of absolute_precision or '
365
+ 'relative_precision')
366
+ if absolute_precision is None:
367
+ #Narrowed for the reader as much as the checker: exactly one of the two
368
+ #is given, so relative_precision is not None on this branch.
369
+ assert relative_precision is not None
370
+ absolute_precision = int(relative_precision) + int(order)
371
+ #Counted in p-adic digits: a hundred decimal digits is worth
372
+ #100*log(10)/log(p) of them, 333 for p=2 and two for a very large prime.
373
+ allowed = p_adic_digits(int(prime))
374
+ if absolute_precision - int(order) > allowed:
375
+ absolute_precision = int(order) + allowed
376
+
377
+ #Constructed rather than assembled by hand, so the coprimality check and
378
+ #the reduction of the unit happen here too.
379
+ value = PAdic(prime, order, unit, absolute_precision)
380
+ return _by_number({'kind': 'Qp', 'prime': value.prime,
381
+ 'valuation': value.valuation, 'unit': str(value.unit),
382
+ 'precision': value.precision_absolute},
383
+ client)
384
+
385
+
386
+ def search_polynomial(polynomial: Union[str, Polynomial],
387
+ client: Optional[Client] = None) -> 'SearchResults':
388
+ """Search for a polynomial over the rationals, written as text.
389
+
390
+ Variable names do not matter: the database canonicalises under renaming, so
391
+ 'x^2-2' and 'y^2-2' find each other.
392
+
393
+ Not the same as passing the text to search_text. A search term might be a
394
+ title or a tag, and because variables are canonicalised away, a single-term
395
+ polynomial would match any word -- so the search bar ignores those. Saying
396
+ "this is a polynomial" removes the ambiguity, and 'x' is searched here
397
+ where it would not be there.
398
+ """
399
+ text = polynomial.text if isinstance(polynomial, Polynomial) \
400
+ else str(polynomial)
401
+
402
+ #Sent as a digest of the canonical key when the polynomial is long. The
403
+ #longest stored one is 58866 characters and a URL is rejected past 8k, so
404
+ #the largest entries could not be asked about at all. Sound because one
405
+ #canonicalisation defines the key and this package carries a byte-identical
406
+ #copy of it; a test asserts the two files never diverge.
407
+ if len(text) > _HASH_ABOVE:
408
+ try:
409
+ from ._polynomial import parse_polynomial as _parse
410
+ return _lookup({'polynomial_hash': _parse(
411
+ text.replace(' ', '')).canonical_hash()}, client)
412
+ except Exception:
413
+ #Unreadable here but perhaps readable by the server, which has the
414
+ #richer parser. Falling back costs a long request, not an answer.
415
+ pass
416
+ #Its own parameter, not text=. Search terms are ambiguous -- a word might
417
+ #be a title or a tag -- and polynomials are canonicalised under renaming of
418
+ #variables, so a single-term polynomial would match any word at all. The
419
+ #search bar ignores those on purpose. Here the caller has said this is a
420
+ #polynomial, so single terms are searched too. Parsing still happens on the
421
+ #server, so this package does not grow a second polynomial parser.
422
+ return _lookup({'polynomial': text}, client)
423
+
424
+
425
+ def search_text(text: str, client: Optional[Client] = None) -> 'SearchResults':
426
+ """Search exactly as the box on the website does.
427
+
428
+ The documented human formats: '3.14159' for a real, '1415' for a
429
+ fractional part, 'Q5:1010' or '1 + O(5^20)' for a p-adic, '1/2 + i*0.866'
430
+ for a complex number, 'x^2-2' for a polynomial.
431
+
432
+ A string states its own precision -- '3.14' means the last digit is
433
+ uncertain -- which is why text is a sound way to search and a bare float
434
+ is not.
435
+ """
436
+ return _lookup({'text': text}, client)
437
+
438
+
439
+ def search_by_expression(expression: str, client: Optional[Client] = None) -> 'SearchResults':
440
+ """Have the server evaluate a Sage expression, and search for the results.
441
+
442
+ The only call that runs code on the server: it forks a sandboxed Sage
443
+ process, so it is much the most expensive, and the rate limit it consumes
444
+ is there because of it. Use it when you want the server to *compute*
445
+ something -- '{n: pi^n for n in [1..5]}' -- not to look up a number you
446
+ already have.
447
+ """
448
+ return _expression(expression, client)
449
+
450
+
451
+ def _expression(expression: str,
452
+ client: Optional[Client]) -> 'SearchResults':
453
+ used = client or _default_client
454
+ payload = used.request('api/search', {'expression': expression})
455
+ records = payload.get('results') or []
456
+ messages = [message.get('text', '') for message in
457
+ (payload.get('messages') or []) if isinstance(message, dict)]
458
+ return SearchResults([Result(record, used.as_sage)
459
+ for record in records], messages)
460
+
461
+
462
+ #: Anything search() accepts. Sage values are matched structurally, by
463
+ #: having a parent -- never by the attributes they expose, since Sage
464
+ #: polynomials and p-adics both answer numerator().
465
+ Searchable = Union[int, Fraction, str, RealInterval, ComplexInterval,
466
+ PAdic, Polynomial, SupportsParent]
467
+
468
+
469
+ def _sage_parent_kind(value: Any) -> Optional[str]:
470
+ """What a Sage value is, judged by its parent.
471
+
472
+ Never by the attributes it exposes: Sage polynomials and p-adics both carry
473
+ numerator() and denominator(), returning objects of their own type, so
474
+ anything sniffing for those would take them for rationals -- and Python's
475
+ Fraction does not raise on a Sage rational, it stores the bound methods.
476
+
477
+ Textual, because the alternative is importing Sage to compare classes, and
478
+ this package must work without it.
479
+ """
480
+ parent = getattr(value, 'parent', None)
481
+ if not callable(parent):
482
+ return None
483
+ try:
484
+ described = str(parent())
485
+ except Exception:
486
+ return None
487
+ lowered = described.lower()
488
+ if 'adic' in lowered:
489
+ return 'p-adic'
490
+ if 'polynomial ring' in lowered:
491
+ return 'polynomial'
492
+ if 'complex interval' in lowered or 'complex ball' in lowered:
493
+ return 'complex interval'
494
+ if 'real interval' in lowered or 'real ball' in lowered:
495
+ return 'real interval'
496
+ if described == 'Rational Field':
497
+ return 'rational'
498
+ if described == 'Integer Ring':
499
+ return 'integer'
500
+ return described
501
+
502
+
503
+ def search(value: 'Searchable', client: Optional[Client] = None) -> 'SearchResults':
504
+ """Search for a number you already have.
505
+
506
+ Accepts a Python ``int`` or ``Fraction``, one of this package's own types
507
+ (``RealInterval``, ``ComplexInterval``, ``PAdic``, ``Polynomial``), or a
508
+ Sage number. For raw components, the ``search_*`` functions take them
509
+ directly and need no object built first.
510
+
511
+ A bare ``float`` is refused. A float states no precision -- its decimal
512
+ digits are an artefact of binary, not a claim about a measurement -- so
513
+ searching for one would silently invent an uncertainty. Say what you know:
514
+ ``search_real_ball(3.14159266, 1e-8)``, or a string, which does state its
515
+ own precision.
516
+ """
517
+ if isinstance(value, bool):
518
+ raise TypeError('a bool is not a number')
519
+
520
+ if isinstance(value, RealInterval):
521
+ return search_real_interval(value.lower, value.upper,
522
+ client=client)
523
+ if isinstance(value, ComplexInterval):
524
+ return search_complex_interval(
525
+ value.real.lower, value.real.upper,
526
+ value.imag.lower, value.imag.upper, client=client)
527
+ if isinstance(value, PAdic):
528
+ return search_p_adic(value.prime, value.valuation, value.unit,
529
+ absolute_precision=value.precision_absolute,
530
+ client=client)
531
+ if isinstance(value, Polynomial):
532
+ return search_polynomial(value, client=client)
533
+
534
+ if isinstance(value, int):
535
+ return search_integer(value, client=client)
536
+ if isinstance(value, Fraction):
537
+ return search_rational(value, client=client)
538
+ if isinstance(value, str):
539
+ return search_text(value, client=client)
540
+
541
+ if isinstance(value, float):
542
+ raise TypeError(
543
+ 'a float states no precision, so searching for one would invent an '
544
+ 'uncertainty it does not have. Use search_real_ball(centre, '
545
+ 'radius) to say what you know, or pass a string, which states its '
546
+ 'own precision.')
547
+
548
+ kind = _sage_parent_kind(value)
549
+ #Classified by parent, so from here it is a Sage object whose interface
550
+ #the type system cannot see. Named as Any rather than pretended about.
551
+ sage_value: Any = value
552
+ if kind == 'integer':
553
+ return search_integer(sage_value, client=client)
554
+ if kind == 'rational':
555
+ return search_rational(sage_value, client=client)
556
+ if kind == 'real interval':
557
+ return search_real_interval(sage_value.lower(), sage_value.upper(),
558
+ client=client)
559
+ if kind == 'complex interval':
560
+ return search_complex_interval(
561
+ sage_value.real().lower(), sage_value.real().upper(),
562
+ sage_value.imag().lower(), sage_value.imag().upper(),
563
+ client=client)
564
+ if kind == 'p-adic':
565
+ if sage_value == 0:
566
+ absolute = int(sage_value.precision_absolute())
567
+ return search_p_adic(int(sage_value.parent().prime()), absolute, 0,
568
+ absolute_precision=absolute,
569
+ client=client)
570
+ return search_p_adic(int(sage_value.parent().prime()),
571
+ int(sage_value.valuation()),
572
+ int(sage_value.unit_part().lift()),
573
+ absolute_precision=int(
574
+ sage_value.precision_absolute()),
575
+ client=client)
576
+ if kind == 'polynomial':
577
+ return search_polynomial(str(sage_value).replace(' ', ''),
578
+ client=client)
579
+
580
+ raise TypeError(
581
+ 'no search for %s. Give an int, a Fraction, a string, one of this '
582
+ "package's types, or a Sage number." % (kind or type(value).__name__,))
583
+
584
+
585
+ def _record_for(value) -> Dict[str, Any]:
586
+ """The wire record for a value, as the typed functions would send it."""
587
+ if isinstance(value, RealInterval):
588
+ low, high = bound_interval(value.lower, value.upper)
589
+ return {'kind': 'RIF', 'lower': str(low), 'upper': str(high)}
590
+ if isinstance(value, ComplexInterval):
591
+ real = bound_interval(value.real.lower, value.real.upper)
592
+ imaginary = bound_interval(value.imag.lower, value.imag.upper)
593
+ return {'kind': 'CIF', 're_lower': str(real[0]),
594
+ 're_upper': str(real[1]), 'im_lower': str(imaginary[0]),
595
+ 'im_upper': str(imaginary[1])}
596
+ if isinstance(value, PAdic):
597
+ return {'kind': 'Qp', 'prime': value.prime,
598
+ 'valuation': value.valuation, 'unit': str(value.unit),
599
+ 'precision': value.precision_absolute}
600
+ if isinstance(value, bool):
601
+ raise TypeError('a bool is not a number')
602
+ if isinstance(value, int):
603
+ return {'kind': 'ZZ', 'value': str(value)}
604
+ if isinstance(value, Fraction):
605
+ return {'kind': 'QQ', 'value': str(value)}
606
+ raise TypeError('cannot batch %s; batches carry numbers, not text or '
607
+ 'expressions' % (type(value).__name__,))
608
+
609
+
610
+ def search_many(values, client: Optional[Client] = None
611
+ ) -> Dict[int, 'SearchResults']:
612
+ """Look up many numbers in one request.
613
+
614
+ One round trip instead of many, which matters more than it sounds: a TLS
615
+ handshake costs about twice what an answered request does, and the rate
616
+ limit counts requests. A batch is priced at one unit plus half per number,
617
+ so a hundred numbers cost fifty-one units rather than a hundred.
618
+
619
+ Returns a dict from position in ``values`` to the results for that number,
620
+ so a caller can tell which answer belongs to which question. Numbers that
621
+ matched nothing are absent; numbers the server could not read appear in the
622
+ messages of every returned group rather than silently vanishing.
623
+
624
+ At most ``MAX_BATCH`` numbers, because one caller should not be able to
625
+ make the server do unbounded work in a single round trip.
626
+ """
627
+ values = list(values)
628
+ if len(values) > MAX_BATCH:
629
+ raise ValueError('at most %d numbers in one batch; %d given. Split it.'
630
+ % (MAX_BATCH, len(values)))
631
+
632
+ records = [_record_for(value) for value in values]
633
+ payload = (client or _default_client).request(
634
+ 'api/lookup', {'numbers': json.dumps(records)})
635
+ messages = [message.get('text', '') for message in
636
+ (payload.get('messages') or []) if isinstance(message, dict)]
637
+
638
+ used = client or _default_client
639
+ grouped = {} # type: Dict[int, SearchResults]
640
+ for record in payload.get('results') or []:
641
+ try:
642
+ index = int(record.get('index', -1))
643
+ except (TypeError, ValueError):
644
+ continue
645
+ grouped.setdefault(index, SearchResults([], messages)).append(
646
+ Result(record, used.as_sage))
647
+ return grouped