From bc34a4e583b3e9672530a41beaaa29b9edddf1f7 Mon Sep 17 00:00:00 2001 From: Michael Orlitzky Date: Thu, 31 Oct 2024 18:34:26 -0400 Subject: [PATCH] src/sage/interfaces/singular.py: use GNU Info to read Singular's info Our Singular interface currently contains a hand-written parser for Singular's "info" file. This commit eliminates the custom parser in favor of launching GNU Info. GNU Info (or its superset, Texinfo) are widespread, portable, and easy to install on all of the systems we support, so in most cases this should be a "free" improvement. The hand-written parser has several drawbacks: * The extra code is a maintenance burden. We should not be wasting our time reimplementing standard tools. * The custom parser is buggy. For example, it is supposed to raise a KeyError when documentation for a non-existent function is requested. However, the parser does not keep track of what section it's in, so, for example, get_docstring("Preface") returns the contents of the preface even though "Preface" is not a Singular function. * The first time documentation is requested, the entire info file is loaded into a dictionary. This wastes a few megabytes of memory for the duration of the Sage session. * The custom parser does not handle compression (GNU Info does transparently), and the end user or people packaging Singular may not be aware of that. If the system installation of Singular has a compressed info file, Sage won't be able to read it. For contrast, the one downside to using GNU Info is that it adds a new runtime dependency to sagelib. To mitigate that, we do not technically require it, and instead raise a warning if the user (a) tries to read the Singular documentation and (b) has managed to find a system without GNU Info. Our singular_console() itself tries to launch GNU Info to display its interactive help, so the additional optional dependency is not so additional except in corner cases, such as a pypi installation of a subset of Sage linked against libsingular but without a full Singular install. --- src/sage/interfaces/singular.py | 114 ++++++++++++++------------------ 1 file changed, 50 insertions(+), 64 deletions(-) diff --git a/src/sage/interfaces/singular.py b/src/sage/interfaces/singular.py index ed883b07105..689773270d7 100644 --- a/src/sage/interfaces/singular.py +++ b/src/sage/interfaces/singular.py @@ -2272,8 +2272,6 @@ def _instancedoc_(self): sage: 'groebner' in singular.groebner.__doc__ True """ - if not nodes: - generate_docstring_dictionary() prefix = """ This function is an automatically generated pexpect wrapper around the Singular @@ -2294,7 +2292,7 @@ def _instancedoc_(self): """ % (self._name,) try: - return prefix + prefix2 + nodes[node_names[self._name]] + return prefix + prefix2 + get_docstring(self._name) except KeyError: return prefix @@ -2310,10 +2308,8 @@ def _instancedoc_(self): sage: 'matrix_expression' in A.nrows.__doc__ True """ - if not nodes: - generate_docstring_dictionary() try: - return nodes[node_names[self._name]] + return get_docstring(self._name) except KeyError: return "" @@ -2341,58 +2337,6 @@ def is_SingularElement(x): return isinstance(x, SingularElement) -nodes = {} -node_names = {} - - -def generate_docstring_dictionary(): - """ - Generate global dictionaries which hold the docstrings for - Singular functions. - - EXAMPLES:: - - sage: from sage.interfaces.singular import generate_docstring_dictionary - sage: generate_docstring_dictionary() - """ - - global nodes - global node_names - - nodes.clear() - node_names.clear() - - new_node = re.compile(r"File: singular\.[a-z]*, Node: ([^,]*),.*") - new_lookup = re.compile(r"\* ([^:]*):*([^.]*)\..*") - - L, in_node, curr_node = [], False, None - - from sage.libs.singular.singular import get_resource - singular_info_file = get_resource('i') - - # singular.hlp contains a few iso-8859-1 encoded special characters - with open(singular_info_file, - encoding='latin-1') as f: - for line in f: - m = re.match(new_node, line) - if m: - # a new node starts - in_node = True - nodes[curr_node] = "".join(L) - L = [] - curr_node, = m.groups() - elif in_node: # we are in a node - L.append(line) - else: - m = re.match(new_lookup, line) - if m: - a, b = m.groups() - node_names[a] = b.strip() - - if line in ("6 Index\n", "F Index\n"): - in_node = False - - nodes[curr_node] = "".join(L) # last node def get_docstring(name): @@ -2410,13 +2354,50 @@ def get_docstring(name): True sage: 'standard.lib' in get_docstring('groebner') True + + TESTS: + + Non-existent functions raise a ``KeyError``:: + + sage: from sage.interfaces.singular import get_docstring + sage: get_docstring("mysql_real_escape_string") + Traceback (most recent call last): + ... + KeyError: 'Singular function "mysql_real_escape_string" not found' + + The first character of the output should be a newline so that the + output from ``singular.?`` looks right:: + + sage: from sage.interfaces.singular import get_docstring + sage: get_docstring("align")[0] + '\n' + """ - if not nodes: - generate_docstring_dictionary() - try: - return nodes[node_names[name]] - except KeyError: - return "" + import subprocess + cmd_and_args = ["info", "--node=%s" % name, "singular"] + result = subprocess.run(cmd_and_args, + capture_output=True, + check=True, + text=True) + + # The subprocess call will succeed even if "name" is junk. But + # since we are expecting to retrieve the docs for a function, upon + # success we should retrieve a subsection of the "Functions" + # section in the Info page. Thus we can check for the string + # "Up: Functions" on the first line of the result (the navigation + # header) to determine whether or not ``name`` had its own entry in + # the Singular "Functions" section. + offset = result.stdout.find("\n") + line0 = result.stdout[:offset] + if not "Up: Functions" in line0: + raise KeyError('Singular function "%s" not found' % name) + + # If the first line was the navigation header, the second line should + # be blank; by incrementing the offset by one, we're skipping over it. + # This leaves an "\n" at the beginning of the string, but running (say) + # "singular.align?" suggests that this is expected. + offset += 1 + return result.stdout[offset:] singular = Singular() @@ -2456,6 +2437,11 @@ def singular_version(): """ Return the version of Singular being used. + OUTPUT: + + A string describing the Singular function ``name``. A ``KeyError`` + is raised if no such function was found in the Singular documentation. + EXAMPLES:: sage: singular.version()