Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 152 additions & 15 deletions lmfdb/groups/abstract/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@
parse_ints,
parse_bool,
clean_input,
parse_regex_restricted,
parse_bracketed_posints,
parse_noop,
parse_group_label_or_order,
dispZmat,
dispcyclomat,
search_wrap,
Expand All @@ -51,7 +49,7 @@
redirect_no_cache,
CodeSnippet,
)
from lmfdb.utils.search_parsing import (parse_multiset, search_parser, collapse_ors)
from lmfdb.utils.search_parsing import (parse_multiset, search_parser, collapse_ors, SearchParsingError)
from lmfdb.utils.interesting import interesting_knowls
from lmfdb.utils.search_columns import SearchColumns, LinkCol, MathCol, CheckCol, SpacerCol, ProcessedCol, MultiProcessedCol, ColGroup
from lmfdb.api import datapage
Expand Down Expand Up @@ -1583,6 +1581,145 @@ def group_search(info, query={}):
group_parse(info, query)


def normalize_group_name(name):
# Best-effort normalization so that TeX-like names copied from the website
# (e.g. C_2^3, C_2\times C_4, S_{4}) resolve the same as their plain forms
# (C2^3, C2xC4, S4).
name = name.strip()
name = name.replace("\\times", "x").replace("\\rtimes", ":").replace("\\ltimes", ":")
name = name.replace("\\", "").replace("_", "").replace("{", "").replace("}", "")
return name.replace(" ", "")


def name_to_label(name):
# Resolve a group name to the label of the group with that name, reusing the
# resolution paths of the jump box (see group_jump). Returns the label as a
# string, None if the name is unrecognized or the group is not in the
# database, and raises SearchParsingError if the name does not determine a
# unique group.
from lmfdb.galois_groups.transitive_group import Tfinder
name = normalize_group_name(name)
if not name:
return None
# already a label
if abstract_group_label_regex.fullmatch(name):
return name
# transitive group, e.g. 8T3
if Tfinder.fullmatch(name):
return db.gps_transitive.lookup(name, "abstract_label")
# product of cyclic groups, e.g. C2^3, C2xC4
if CYCLIC_PRODUCT_RE.fullmatch(name):
invs = [n.strip() for n in name.upper().replace("C", "").replace("X", "*").replace("^", "_").split("*")]
primary = canonify_abelian_label(".".join(invs))
if [z for z in primary if z > 2**31 - 1]:
return None
return db.gps_groups.lucky({"abelian": True, "primary_abelian_invariants": primary}, "label")
# stored name, e.g. S4, D8, C2*A5, SL(2,7) (also accept x for the * separator)
candidates = {name, name.replace("x", "*").replace("X", "*")}
labels = list(db.gps_groups.search({"name": {"$in": list(candidates)}}, projection="label", limit=2))
if len(labels) == 1:
return labels[0]
if len(labels) >= 2:
raise SearchParsingError(f"The name {name} does not determine a unique group; please enter a label")
# special name from a family, e.g. GL(2,3), PSL(2,7)
def int_try(x):
return int(x) if x.isdigit() else x
for family in db.gps_families.search():
m = re.fullmatch(family["input"], name)
if m:
m_dict = dict([a, int_try(x)] for a, x in m.groupdict().items())
lab = db.gps_special_names.lucky({"family": family["family"], "parameters": m_dict}, projection="label")
if lab:
return lab
return None


CLOSING_DELIMITERS = {")": "(", "]": "[", "}": "{"}


def split_group_search_terms(inp):
# Split a comma-separated list of labels, orders and group names, ignoring
# commas inside grouping delimiters so that a name like SL(2,7) survives as
# a single entry. Unlike split_top_level_commas (used for jump boxes, where
# unbalanced input is passed on to the jump logic) malformed input is
# rejected here, since a search box can report the problem to the user.
stack = []
terms = []
current = []
for c in inp:
if c in CLOSING_DELIMITERS.values():
stack.append(c)
elif c in CLOSING_DELIMITERS:
if not stack or stack.pop() != CLOSING_DELIMITERS[c]:
raise SearchParsingError("Mismatched parentheses or brackets")
if c == "," and not stack:
terms.append("".join(current))
current = []
else:
current.append(c)
if stack:
raise SearchParsingError("Mismatched parentheses or brackets")
terms.append("".join(current))
if not all(terms):
raise SearchParsingError("Comma-separated list has an empty entry")
return terms


def strip_unary_plus(z):
# We used to remove every + from the input (prep_plus), which accepted a
# unary + on an order or label but also mangled names like SO+(4,2).
return z[1:] if z.startswith("+") else z


@search_parser(clean_info=True)
def parse_group_label_or_order_or_name(inp, query, qfield, regex):
# Like parse_group_label_or_order, but comma-separated entries may also be
# group names (e.g. C6, S4, C2^3, SL(2,7)), resolved to labels.
orders = []
labels = []
for z in map(strip_unary_plus, split_group_search_terms(inp)):
if re.fullmatch(r'\d+', z):
orders.append({'$startswith': f'{z}.'})
elif regex.fullmatch(z):
labels.append(z)
else:
lab = name_to_label(z)
if lab is None:
raise SearchParsingError(f"{z} is not a valid group label, order or name")
labels.append(lab)
if labels:
if len(labels) == 1:
labelquery = labels[0]
else:
labelquery = {"$in": labels}
if orders:
if labels:
query[qfield] = {"$or": orders + [labelquery]}
else:
query[qfield] = {"$or": orders}
else:
query[qfield] = labelquery


@search_parser
def parse_group_label_or_name(inp, query, qfield, regex):
# Like parse_regex_restricted for group labels, but comma-separated entries
# may also be group names (e.g. C6, S4, C2^3, SL(2,7)), resolved to labels.
labels = []
for z in split_group_search_terms(inp):
if regex.fullmatch(z):
labels.append(z)
else:
lab = name_to_label(z)
if lab is None:
raise SearchParsingError(f"{z} is not a valid group label or name")
labels.append(lab)
if len(labels) == 1:
query[qfield] = labels[0]
else:
query[qfield] = {"$in": labels}


def group_parse(info, query):
parse_ints(info, query, "order", "order")
parse_ints(info, query, "exponent", "exponent")
Expand Down Expand Up @@ -1625,22 +1762,22 @@ def group_parse(info, query):
parse_bool(info, query, "rational", "is rational")
parse_bool(info, query, "wreath_product", "is wreath product")
parse_bracketed_posints(info, query, "exponents_of_order", "exponents_of_order")
parse_group_label_or_order(info, query, "center_label", regex=abstract_group_label_regex)
parse_regex_restricted(info, query, "aut_group", regex=abstract_group_label_regex)
parse_group_label_or_order(info, query, "commutator_label", regex=abstract_group_label_regex)
parse_group_label_or_order(
parse_group_label_or_order_or_name(info, query, "center_label", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "aut_group", regex=abstract_group_label_regex)
parse_group_label_or_order_or_name(info, query, "commutator_label", regex=abstract_group_label_regex)
parse_group_label_or_order_or_name(
info, query, "central_quotient", regex=abstract_group_label_regex
)
parse_group_label_or_order(
parse_group_label_or_order_or_name(
info, query, "abelian_quotient", regex=abstract_group_label_regex
)
#parse_regex_restricted(
# info, query, "schur_multiplier", regex=abstract_group_label_regex
#)
parse_regex_restricted(
parse_group_label_or_name(
info, query, "frattini_label", regex=abstract_group_label_regex
)
parse_regex_restricted(info, query, "outer_group", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "outer_group", regex=abstract_group_label_regex)
parse_noop(info, query, "name")
parse_ints(info, query, "order_factorization_type")
parse_family(info, query, "family", qfield="label")
Expand Down Expand Up @@ -1736,9 +1873,9 @@ def subgroup_search(info, query={}):
info, query, "hall", process=lambda x: ({"$gt": 1} if x else {"$lte": 1})
)
parse_bool(info, query, "nontrivproper", qfield="proper")
parse_regex_restricted(info, query, "subgroup", regex=abstract_group_label_regex)
parse_regex_restricted(info, query, "ambient", regex=abstract_group_label_regex)
parse_regex_restricted(info, query, "quotient", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "subgroup", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "ambient", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "quotient", regex=abstract_group_label_regex)

def print_type(val):
if val == 0:
Expand Down Expand Up @@ -1828,9 +1965,9 @@ def complex_char_search(info, query={}):
parse_ints(info, query, "center_index")
parse_ints(info, query, "kernel_order")
#parse_bracketed_posints(info,query,"nt",split=False,keepbrackets=True, allow0=False)
parse_regex_restricted(info, query, "group", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "group", regex=abstract_group_label_regex)
# parse_regex_restricted(info, query, "center", regex=abstract_group_label_regex)
parse_regex_restricted(info, query, "image_isoclass", regex=abstract_group_label_regex)
parse_group_label_or_name(info, query, "image_isoclass", regex=abstract_group_label_regex)
# parse_regex_restricted(info, query, "kernel", regex=abstract_group_label_regex)


Expand Down
112 changes: 112 additions & 0 deletions lmfdb/groups/abstract/test_browse_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,118 @@ def test_frattini_label_search(self):
self.check_args("/Groups/Abstract/?frattini_label=4.2", "16.2")
self.not_check_args("/Groups/Abstract/?frattini_label=4.2", "5.1")

def test_search_by_name(self):
r"""
Check that group-label search boxes also accept group names, resolving
them to labels (issue #6397). Each case uses a name whose label is
already checked in a sibling test above, so name and label agree.
"""
# center C2^3 == center 8.5 (cf. test_search_center)
self.check_args("/Groups/Abstract/?center_label=C2^3", ["64.212", "80.43"])
# aut_group C6 == aut_group 6.2 (cf. test_search_autgroup)
self.check_args("/Groups/Abstract/?aut_group=C6", ["7.1", "18.2"])
# commutator C8 == commutator 8.1 (cf. test_search_commutator)
self.check_args("/Groups/Abstract/?commutator_label=C8", ["32.20", "64.190"])
# central quotient C2^2 == central quotient 4.2 (cf. test_search_centralquot)
self.check_args("/Groups/Abstract/?central_quotient=C2^2", ["40.10", "64.87"])
# abelianization C8 == abelianization 8.1 (cf. test_search_abelianization)
self.check_args("/Groups/Abstract/?abelian_quotient=C8", ["72.19", "96.65"])
# Frattini C2^2 == Frattini 4.2 (cf. test_frattini_label_search)
self.check_args("/Groups/Abstract/?frattini_label=C2^2", "16.2")
# outer group C2^2 == outer group 4.2 (cf. test_outer_group_search)
self.check_args("/Groups/Abstract/?outer_group=C2^2", "8.1")
# TeX-like input is normalized (C_2^3 -> C2^3 -> 8.5)
self.check_args("/Groups/Abstract/?center_label=C_2^3", "64.212")
# a comma separated list may mix orders and names: the groups of order
# 6-8 whose center is of order 2 or is C6 are C6, D4 and Q8
self.check_args("/Groups/Abstract/?center_label=2,C6&order=6-8", ["6.2", "8.3", "8.4"])

def test_search_by_name_subgroups(self):
r"""
Check that names work in the subgroup and complex character searches.
"""
# ambient C2^3 resolves to 8.5
self.check_args("/Groups/Abstract/?search_type=Subgroups&ambient=C2^3", "8.5")
# group A5 resolves to 60.5 in the complex character search
self.check_args("/Groups/Abstract/?search_type=ComplexCharacters&group=A5", "60.5")

def test_search_by_name_with_commas(self):
r"""
Check that a family name whose parameters contain commas, such as
SL(2,7) = 336.114 or GL(2,3) = 48.29, is kept as a single entry rather
than being split at its internal comma.
"""
# a label only box (parse_group_label_or_name): Aut(G) = GL(2,3)
self.check_args("/Groups/Abstract/?aut_group=GL(2,3)", ["9.2", "18.5"])
# an order or name box (parse_group_label_or_order_or_name): [G,G] = SL(2,7)
self.check_args("/Groups/Abstract/?commutator_label=SL(2,7)", "336.114")
# and the subgroup search
self.check_args("/Groups/Abstract/?search_type=Subgroups&ambient=SL(2,7)", "336.114")
# only top level commas split the list: GL(2,3) = 48.29 and C6 = 6.2
self.check_args("/Groups/Abstract/?aut_group=GL(2,3),C6", ["9.2", "18.5", "7.1", "18.2"])
# such a name may also be mixed with an order in an order or name box
self.check_args(
"/Groups/Abstract/?commutator_label=8,SL(2,7)&order=336",
["336.114", "336.169", "336.170", "336.213"],
)

def test_search_by_name_with_plus(self):
r"""
Check that a + inside a name survives, while a leading unary + on an
order or label is still accepted. SO+(4,2) is 72.40, which has trivial
center and so occurs as a central quotient.
"""
self.check_args(
"/Groups/Abstract/?central_quotient=SO%2B(4%2C2)&order=144",
["144.115", "144.186"],
)
# +8 still means "center of order 8", +8.1 still means "center C8"
self.check_args("/Groups/Abstract/?center_label=%2B8&order=8", ["8.1", "8.2", "8.5"])
self.check_args("/Groups/Abstract/?center_label=%2B8.1&order=8", "8.1")
self.not_check_args("/Groups/Abstract/?center_label=%2B8.1&order=8", "8.2")

def test_search_by_bad_name(self):
r"""
Check that an unrecognized name in a search box gives a sensible error.
"""
self.check_args("/Groups/Abstract/?center_label=nonsensexyz", "Abstract groups search input error")
self.check_args("/Groups/Abstract/?aut_group=nonsensexyz", "Abstract groups search input error")
# unbalanced delimiters are rejected as such, rather than being split at
# the internal comma and reported as a truncated name
for url in ["/Groups/Abstract/?aut_group=SL(2,7",
"/Groups/Abstract/?commutator_label=SL(2,7"]:
self.check_args(url, "Abstract groups search input error")
self.not_check_args(url, "SL(2 is not")
self.check_args("/Groups/Abstract/?search_type=Subgroups&ambient=SL(2,7", "Subgroup search input error")
self.not_check_args("/Groups/Abstract/?search_type=Subgroups&ambient=SL(2,7", "SL(2 is not")
# as are empty entries in the comma separated list
self.check_args("/Groups/Abstract/?center_label=C2,,C6", "Abstract groups search input error")

def test_search_by_bad_name_in_debug_mode(self):
r"""
An unrecognized name says what is wrong with the input; it is not a bug.
So the search page is redisplayed with the error message at the top even
when the site is being run with debug enabled, rather than the developer
being shown a traceback (LMFDB#7173).
"""
debug = self.app.debug
self.app.debug = True
try:
self.check_args(
"/Groups/Abstract/?aut_group=bird",
["Abstract groups search input error", "bird is not a valid group label or name"],
)
self.check_args(
"/Groups/Abstract/?search_type=Subgroups&ambient=bird",
["Subgroup search input error", "bird is not a valid group label or name"],
)
self.check_args(
"/Groups/Abstract/?search_type=ComplexCharacters&group=bird",
["Complex character search input error", "bird is not a valid group label or name"],
)
finally:
self.app.debug = debug

def test_supersolvable_search(self):
r"""
Check that we can restrict to supersolvable groups or not only
Expand Down
7 changes: 5 additions & 2 deletions lmfdb/utils/search_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,11 @@ def make_query(self, info, random=False):
errpage = self.f(info, query)
parse_labels(info, query, self.table)
except Exception as err:
# Errors raised in parsing; these should mostly be SearchParsingErrors
if is_debug_mode():
# Errors raised in parsing. A SearchParsingError reports invalid
# user input rather than a bug, so we redisplay the search page with
# the error message even in debug mode; anything else is re-raised
# so that developers see the traceback.
if is_debug_mode() and not isinstance(err, SearchParsingError):
raise
info["err"] = str(err)
err_title = query.pop("__err_title__", self.err_title)
Expand Down
Loading