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 +647 -0
- numberdb/_convert.py +141 -0
- numberdb/_errors.py +50 -0
- numberdb/_http.py +220 -0
- numberdb/_limits.py +116 -0
- numberdb/_polynomial.py +379 -0
- numberdb/_wire.py +396 -0
- numberdb/py.typed +0 -0
- numberdb/sage.py +165 -0
- numberdb-0.0.1.dist-info/METADATA +192 -0
- numberdb-0.0.1.dist-info/RECORD +14 -0
- numberdb-0.0.1.dist-info/WHEEL +5 -0
- numberdb-0.0.1.dist-info/licenses/LICENSE +21 -0
- numberdb-0.0.1.dist-info/top_level.txt +1 -0
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
|