From 4314aa8e2355b0806bc88893b53bbe7948d727d1 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 | 131 ++++++++++++++++---------------- 1 file changed, 65 insertions(+), 66 deletions(-) diff --git a/src/sage/interfaces/singular.py b/src/sage/interfaces/singular.py index ed883b07105..c53b06f3395 100644 --- a/src/sage/interfaces/singular.py +++ b/src/sage/interfaces/singular.py @@ -2269,11 +2269,9 @@ def _instancedoc_(self): """ EXAMPLES:: - sage: 'groebner' in singular.groebner.__doc__ + sage: 'groebner' in singular.groebner.__doc__ # needs info 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 @@ -2307,13 +2305,11 @@ def _instancedoc_(self): sage: R = singular.ring(0, '(x,y,z)', 'dp') sage: A = singular.matrix(2,2) - sage: 'matrix_expression' in A.nrows.__doc__ + sage: 'matrix_expression' in A.nrows.__doc__ # needs info 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): @@ -2408,15 +2352,65 @@ def get_docstring(name): sage: from sage.interfaces.singular import get_docstring sage: 'groebner' in get_docstring('groebner') True - sage: 'standard.lib' in get_docstring('groebner') + sage: 'standard.lib' in get_docstring('groebner') # needs info True + + TESTS: + + Non-existent functions raise a ``KeyError``:: + + sage: from sage.interfaces.singular import get_docstring + sage: get_docstring("mysql_real_escape_string") # needs info + Traceback (most recent call last): + ... + KeyError: 'mysql_real_escape_string' + + 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] # needs info + '\n' + + If GNU Info is not installed, we politely decline to do anything:: + + sage: from sage.interfaces.singular import get_docstring + sage: from sage.features.info import Info + sage: Info().hide() + sage: get_docstring('groebner') + Traceback (most recent call last): + ... + OSError: Error: GNU Info is not installed. Singular's + documentation will not be available. + sage: Info().unhide() + """ - if not nodes: - generate_docstring_dictionary() + from sage.features.info import Info + + if not Info().is_present(): + raise OSError("Error: GNU Info is not installed. Singular's " + "documentation will not be available.") + import subprocess + cmd_and_args = ["info", f"--node={name}", "singular"] try: - return nodes[node_names[name]] - except KeyError: - return "" + result = subprocess.run(cmd_and_args, + capture_output=True, + check=True, + text=True) + except subprocess.CalledProcessError as e: + # Before Texinfo v7.0.0, the "info" program would exit + # successfully even if the desired node was not found. + raise KeyError(name) from e + + # The first line we get back is the navigation header for the + # function, and the second is blank, meaning that there are two + # newlines in a row. By incrementing the offset here, we're + # skipping over one of them. This leaves an "\n" at the beginning + # of the string, but running (say) "singular.align?" suggests + # that this is expected, or at least is backwards-compatible + # with the previous implementation. + offset = result.stdout.find("\n") + 1 + return result.stdout[offset:] singular = Singular() @@ -2456,6 +2450,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()