|
| 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