Skip to content

Commit 806c01a

Browse files
committed
Add command line LMFDB interface
1 parent be4b423 commit 806c01a

1 file changed

Lines changed: 321 additions & 0 deletions

File tree

lmfdb_search

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
#!/usr/bin/env -S sage -python
2+
3+
# Other features we could add:
4+
# * Random object
5+
# * Raw SQL parsing
6+
# * Another database connection (other than devmirror)
7+
8+
# TODO: Add json and tsv language support to downloads
9+
# TODO: Tests
10+
11+
import sys
12+
import json
13+
from argparse import ArgumentParser
14+
from urllib.parse import parse_qs
15+
from lmfdb import db
16+
from lmfdb.utils.completeness import results_complete
17+
from psycodict.encoding import copy_dumps
18+
19+
parser = ArgumentParser(
20+
prog="LMFDBSearch",
21+
description="""Command-line search interface for the L-functions and modular forms database (LMFDB)
22+
23+
You can provide input in two formats:
24+
* A url string, matching the query-string part of an LMFDB url. For example, searching for number fields with degree 4 would be done with degree=4, matching the LMFDB url https://www.lmfdb.org/NumberField/?degree=4.
25+
* A json dictionary such as {"degree":4}.
26+
""")
27+
28+
parser.add_argument("table", help="Which table or section of the lmfdb to search")
29+
parser.add_argument("query", nargs="*", help="A query constraining which results are returned. If not present, all results will be included")
30+
parser.add_argument("-i", "--input", help="Input file with search query", )
31+
parser.add_argument("-s", "--sql", action="store_true", help="Use SQL for input")
32+
parser.add_argument("-o", "--output", help="Output file for search results")
33+
parser.add_argument("-f", "--force", action="store_true", help="Overwrite output file even if it exists")
34+
parser.add_argument("-t", "--format", help="Output format", choices=["raw", "text", "json", "csv", "tsv", "sage", "gap", "pari", "magma", "oscar"], default="raw")
35+
parser.add_argument("-p", "--completeness", action="store_true", help="Whether to include completeness information at the begining of the output")
36+
parser.add_argument("-c", "--cols", help="Which columns to include in the output")
37+
parser.add_argument("-l", "--limit", type=int, help="The number of matching results to return (-1 for no limit)", default=50)
38+
parser.add_argument("--offset", type=int, help="Where to start in the list of results", default=0)
39+
parser.add_argument("--sort", help="columns by which to sort the search results; prepend a minus sign to a column to reverse it")
40+
parser.add_argument("--oneper", help="a list of columns, separated by commas. If provided, only one result will be included with each given set of values for those columns (the first according to the provided sort order).")
41+
parser.add_argument("-d", "--debug", action="store_true", help="whether to raise errors")
42+
parser.add_argument("--host", help="PostgreSQL server host or socket directory [default: %(default)s]", default="devmirror.lmfdb.xyz")
43+
parser.add_argument("--port", type=int, help="PostreSQL server port [default: $(default)d]", default=5432)
44+
parser.add_argument("--user", help="PostgreSQL username [default: %(default)s]", default="lmfdb")
45+
parser.add_argument("--password", help="PostgreSQL password [default: %(default)s]", default="lmfdb")
46+
parser.add_argument("--dbname", help="PostgreSQL database name [default: %(default)s]", default="lmfdb")
47+
48+
args = parser.parse_args()
49+
50+
def section_lookup(section):
51+
if section == "L":
52+
from lmfdb.lfunctions.main import l_function_search, LFunctionSearchArray
53+
return l_function_search, LFunctionSearchArray()
54+
if section == "L/rational":
55+
from lmfdb.lfunctions.main import l_function_search, LFunctionSearchArray
56+
return l_function_search, LFunctionSearchArray(force_rational=True)
57+
if section == "ModularForm/GL2/Q/holomorphic":
58+
from lmfdb.classical_modular_forms.main import newform_search, CMFSearchArray
59+
return newform_search, CMFSearchArray()
60+
if section == "ModularForm/GL2/Q/Maass":
61+
from lmfdb.maass.main import search, MaassSearchArray
62+
return search, MaassSearchArray()
63+
if section == "ModularForm/GL2/TotallyReal":
64+
from lmfdb.hilbert_modular_forms.hilbert_modular_form import hilbert_modular_form_search, HMFSearchArray
65+
return hilbert_modular_form_search, HMFSearchArray()
66+
if section == "ModularForm/GL2/ImaginaryQuadratic":
67+
from lmfdb.bianchi_modular_forms.bianchi_modular_form import bianchi_modular_form_search, BMFSearchArray
68+
return bianchi_modular_form_search, BMFSearchArray()
69+
if section == "EllipticCurve/Q":
70+
from lmfdb.elliptic_curves.elliptic_curve import elliptic_curve_search, ECSearchArray
71+
return elliptic_curve_search, ECSearchArray()
72+
if section == "EllipticCurve":
73+
from lmfdb.ecnf.main import elliptic_curve_search, ECNFSearchArray
74+
return elliptic_curve_search, ECNFSearchArray()
75+
if section == "Genus2Curve/Q":
76+
from lmfdb.genus2_curves.main import genus2_curve_search, G2CSearchArray
77+
return genus2_curve_search, G2CSearchArray()
78+
if section == "ModularCurve/Q":
79+
from lmfdb.modular_curves.main import modcurve_search, ModCurveSearchArray
80+
return modcurve_search, ModCurveSearchArray()
81+
if section == "HigherGenus/C/Aut":
82+
from lmfdb.higher_genus_w_automorphisms.main import higher_genus_w_automorphisms_search, HGCWASearchArray
83+
return higher_genus_w_automorphisms_search, HGCWASearchArray()
84+
if section == "Variety/Abelian/Fq":
85+
from lmfdb.abvar.fq.main import abelian_variety_search, AbvarSearchArray
86+
return abelian_variety_search, AbvarSearchArray()
87+
if section == "Belyi":
88+
from lmfdb.belyi.main import belyi_search, BelyiSearchArray
89+
return belyi_search, BelyiSearchArray()
90+
if section == "NumberField":
91+
from lmfdb.number_fields.number_field import number_field_search, NFSearchArray
92+
return number_field_search, NFSearchArray()
93+
if section == "padicField":
94+
from lmfdb.local_fields.main import local_field_search, LFSearchArray
95+
return local_field_search, LFSearchArray()
96+
if section == "Character/Dirichlet/":
97+
from lmfdb.characters.main import dirichlet_character_search, DirichSearchArray
98+
return dirichlet_character_search, DirichSearchArray()
99+
if section == "ArtinRepresentation":
100+
from lmfdb.artin_representations.main import artin_representation_search, ArtinSearchArray
101+
return artin_representation_search, ArtinSearchArray()
102+
if section == "Motive/Hypergeometric/Q":
103+
from lmfdb.hypergm.main import hgm_search, HGMSearchArray
104+
return hgm_search, HGMSearchArray()
105+
if section == "GaloisGroup":
106+
from lmfdb.galois_groups.main import galois_group_search, GalSearchArray
107+
return galois_group_search, GalSearchArray()
108+
if section == "SatoTateGroup":
109+
from lmfdb.sato_tate_groups import sato_tate_search, STSearchArray
110+
return sato_tate_search, STSearchArray()
111+
if section == "Groups/Abstract":
112+
from lmfdb.groups.abstract.main import group_search, GroupsSearchArray
113+
return group_search, GroupsSearchArray()
114+
if section == "Lattice":
115+
from lmfdb.lattice.main import lattice_search, LatSearchArray
116+
return lattice_search, LatSearchArray()
117+
# TODO: include tabs (e.g. families for p-adic fields, newspaces
118+
119+
parser.error("Must provide an LMFDB table or section as the first argument")
120+
121+
def print_help(table, col=None):
122+
# TODO: Add useful help
123+
pass
124+
125+
def write(s, F):
126+
if F is None:
127+
if s and s[-1] == "\n":
128+
s = s[:-1]
129+
print(s)
130+
else:
131+
_ = F.write(s)
132+
133+
#if args.host or args.port or args.user or args.password or args.dbname:
134+
# parser.error("Specifying a non default host is not yet supported")
135+
136+
if args.cols is not None:
137+
projection = args.cols = args.cols.split(",")
138+
if any(col not in table.search_cols for col in projection):
139+
parser.error("Invalid columns in output: " + ",".join(col for col in projection if col not in table.search_cols))
140+
else:
141+
projection = 1
142+
143+
if args.table == "help":
144+
# Support giving a table name via a second positional argument, such as
145+
if not args.query:
146+
parser.print_help()
147+
elif len(args.query) <= 2:
148+
print_help(*args.query)
149+
else:
150+
parser.error("Too many positional arguments")
151+
sys.exit(1)
152+
elif len(args.query) > 1:
153+
parser.error("Too many positional arguments")
154+
elif args.table in db.tablenames:
155+
section_search = False
156+
table, search_array = db[args.table], None
157+
if args.format not in ["raw", "json", "csv", "tsv"]:
158+
parser.error(f"{args.format} output format not supported for LMFDB tables, only for sections")
159+
else:
160+
section_search = True
161+
wrapper, search_array = section_lookup(args.table)
162+
table = wrapper.table
163+
if ("download" not in wrapper.shortcuts and args.format not in ["raw", "json", "csv", "tsv"] or
164+
"download" in wrapper.shortcuts and args.format not in ["raw"] + list(wrapper.shortcuts["download"].languages)):
165+
parser.error(f"{args.format} output format not supported for {args.table}")
166+
projection = [col for col in wrapper.projection if col in table.search_cols]
167+
168+
if args.query and args.input is not None:
169+
parser.error("may only provide input from one source: input file or positional argument")
170+
elif args.input is not None:
171+
try:
172+
with open(args.input) as F:
173+
args.query = [F.read()]
174+
except IOError as err:
175+
if args.debug:
176+
raise
177+
parser.error(str(err))
178+
179+
# Autodetect input format based on query
180+
# Must be compatible with table specification
181+
sort = one_per = raw = None
182+
if args.debug:
183+
print("QUERY", args.query)
184+
if not args.query:
185+
query = {}
186+
elif args.sql:
187+
query = {}
188+
raw = args.query[0]
189+
elif args.query[0][0] == "{":
190+
try:
191+
query = json.loads(args.query[0])
192+
except Exception as err:
193+
if args.debug:
194+
raise
195+
parser.error(str(err))
196+
else:
197+
info = parse_qs(args.query[0])
198+
for key, val in info.items():
199+
if len(val) > 1:
200+
parser.error(f"Multiple values found for {key} in query string")
201+
info[key] = val[0]
202+
info["search_array"] = search_array
203+
query, sort, table, title, err_title, template, one_per = wrapper.make_query(info)
204+
205+
if args.oneper:
206+
# one_per might also have been set by url, but we allow the user to override with this setting
207+
one_per = args.oneper.split(",")
208+
if any(col not in table.search_cols for col in one_per):
209+
parser.error(",".join(col for col in one_per if col not in table.search_cols) + " not columns of " + table.search_table)
210+
211+
if args.sort:
212+
# sort might also have been set by url, but we allow the user to override with this setting
213+
sort = args.sort.split(",")
214+
sort = [(col[1:], -1) if col and col[0] == "-" else (col, 1) for col in sort]
215+
if any(col not in table.search_cols for col,asc in sort):
216+
parser.error(",".join(col for col,asc in sort if col not in table.search_cols) + " not columns of " + table.search_table)
217+
218+
if args.limit == -1:
219+
args.limit = None
220+
221+
res = table.search(query,
222+
projection=projection,
223+
limit=args.limit,
224+
offset=args.offset,
225+
sort=sort,
226+
one_per=one_per,
227+
raw=raw)
228+
if projection == 1:
229+
projection = table.search_cols
230+
231+
if section_search and args.format not in ["raw", "json"]:
232+
try:
233+
if wrapper.cleaners:
234+
if limit is None:
235+
res = list(res)
236+
for v in res:
237+
for name, func in wrapper.cleaners.items():
238+
v[name] = func(v)
239+
if wrapper.postprocess is not None:
240+
# This could be expensive, since postprocess functions assume that res usually has at most 50 items
241+
res = wrapper.postprocess(res, info, query)
242+
except Exception as err:
243+
if args.debug:
244+
raise
245+
parser.error(str(err))
246+
if args.format == "json" and args.limit is None:
247+
res = list(res)
248+
249+
if args.output:
250+
if os.path.exists(args.output) and not args.force:
251+
parser.error("Output file already exists")
252+
try:
253+
fkwds = {}
254+
if args.format in ["csv", "tsv"]:
255+
fkwds["newline"] = ""
256+
F = open(args.output, "w", **fkwds)
257+
except Exception as err:
258+
if args.debug:
259+
raise
260+
parser.error(str(err))
261+
elif args.format in ["csv", "tsv"]:
262+
F = sys.stdout
263+
else:
264+
F = None
265+
try:
266+
if args.completeness:
267+
complete, msg, caveat = results_complete(table.search_table, query, table._db, search_array)
268+
if args.format == "json":
269+
res = {
270+
"complete": complete,
271+
"completeness_msg": msg,
272+
"completeness_caveat": caveat,
273+
"results": res
274+
}
275+
elif complete:
276+
write("Complete, since the LMFDB contains all " + msg + "\n", F)
277+
if caveat is None:
278+
write("No reliance on unproven conjectures\n", F)
279+
else:
280+
write("The completeness " + caveat + "\n", F)
281+
else:
282+
write("Not guaranteed complete\n\n", F)
283+
if args.format == "raw":
284+
write("|".join(projection) + "\n", F)
285+
write("|".join([table.col_type[col] for col in projection]) + "\n\n", F)
286+
for rec in res:
287+
line = "|".join(copy_dumps(rec.get(col,None), table.col_type[col]) for col in projection)
288+
write(line + "\n", F)
289+
elif section_search and "download" in wrapper.shortcuts:
290+
dl = wrapper.shortcuts["download"]
291+
lang = dl.languages[args.format]
292+
columns = wrapper.columns
293+
if args.cols is None:
294+
cols = [col for col in columns.columns_shown(info, rank=-1) if col.default(info)]
295+
else:
296+
cols = [col for col in columns.columns if col.name in args.cols]
297+
for s in lang.assign_iter("data", lang.to_lang_iter(
298+
map(
299+
lambda rec: [col.download(dl.postprocess(rec, info, query)) for col in cols],
300+
res))):
301+
write(s, F)
302+
elif args.format == "json":
303+
write(json.dumps(res), F)
304+
else:
305+
if args.format == "csv":
306+
delimiter = ","
307+
elif args.format == "tsv":
308+
delimiter="\t"
309+
else:
310+
raise ValueError(f"Format {args.format} not valid")
311+
writer = csv.DictWriter(F, projection, delimiter=delimiter)
312+
writer.writeheader()
313+
for rec in res:
314+
writer.writerow(rec)
315+
except Exception as err:
316+
if args.debug:
317+
raise
318+
parser.error(str(err))
319+
finally:
320+
if F is not None and F is not sys.stdout:
321+
F.close()

0 commit comments

Comments
 (0)