diff --git a/qldpc/external/codes.py b/qldpc/external/codes.py index 62ead52a..268c8d0f 100644 --- a/qldpc/external/codes.py +++ b/qldpc/external/codes.py @@ -21,7 +21,7 @@ import re import qldpc.cache -from qldpc.external.groups import gap_is_installed, get_gap_result +import qldpc.external.gap CACHE_NAME = "qldpc_codes" @@ -31,7 +31,7 @@ def get_code(code: str) -> tuple[list[list[int]], int | None]: """Retrieve a group from GAP.""" # run GAP commands - if not gap_is_installed(): + if not qldpc.external.gap.is_installed(): raise ValueError("GAP 4 is not installed") commands = [ 'LoadPackage("guava");', @@ -40,7 +40,7 @@ def get_code(code: str) -> tuple[list[list[int]], int | None]: r'Print(LeftActingDomain(code), "\n");', r'for vec in mat do Print(List(vec, x -> Int(x)), "\n"); od;', ] - result = get_gap_result(*commands) + result = qldpc.external.gap.get_result(*commands) if "guava package is not available" in result.stdout: raise ValueError("GAP package GUAVA not available") diff --git a/qldpc/external/codes_test.py b/qldpc/external/codes_test.py index 61c776bb..25f07aec 100644 --- a/qldpc/external/codes_test.py +++ b/qldpc/external/codes_test.py @@ -39,7 +39,7 @@ def test_get_code() -> None: # GAP is not installed with ( - unittest.mock.patch("qldpc.external.codes.gap_is_installed", return_value=False), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=False), pytest.raises(ValueError, match="GAP 4 is not installed"), ): external.codes.get_code("") @@ -47,8 +47,8 @@ def test_get_code() -> None: # GUAVA is not installed mock_process = get_mock_process("guava package is not available") with ( - unittest.mock.patch("qldpc.external.codes.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.codes.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), pytest.raises(ValueError, match="GAP package GUAVA not available"), ): external.codes.get_code("") @@ -56,8 +56,8 @@ def test_get_code() -> None: # code not recognized by GUAVA mock_process = get_mock_process("\n") with ( - unittest.mock.patch("qldpc.external.codes.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.codes.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), pytest.raises(ValueError, match="Code not recognized"), ): external.codes.get_code("") @@ -65,15 +65,15 @@ def test_get_code() -> None: check = [1, 1] mock_process = get_mock_process(f"\n{check}\nGF(3^3)") with ( - unittest.mock.patch("qldpc.external.codes.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.codes.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), ): assert external.codes.get_code("") == ([check], 27) mock_process = get_mock_process(r"\nGF(3^3)") with ( - unittest.mock.patch("qldpc.external.codes.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.codes.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), pytest.raises(ValueError, match="has no parity checks"), ): assert external.codes.get_code("") diff --git a/qldpc/external/gap.py b/qldpc/external/gap.py new file mode 100644 index 00000000..46a3333c --- /dev/null +++ b/qldpc/external/gap.py @@ -0,0 +1,52 @@ +"""Module for communicating with the GAP computer algebra system + +Copyright 2023 The qLDPC Authors and Infleqtion Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from __future__ import annotations + +import re +import subprocess +from collections.abc import Sequence + + +def is_installed() -> bool: + """Is GAP 4 installed?""" + commands = ["gap", "-q", "-c", r'Print(GAPInfo.Version, "\n"); QUIT;'] + try: + result = subprocess.run(commands, capture_output=True, text=True) + return bool(re.match(r"\n4\.[0-9]+\.[0-9]+$", result.stdout)) + except Exception: + return False + + +def sanitize_commands(commands: Sequence[str]) -> tuple[str, ...]: + """Sanitize GAP commands: don't format Print statements, and quit at the end.""" + stream = "__stream__" + prefix = [ + f"{stream} := OutputTextUser();", + f"SetPrintFormattingStatus({stream}, false);", + ] + suffix = ["QUIT;"] + commands = [cmd.replace("Print(", f"PrintTo({stream}, ") for cmd in commands] + return tuple(prefix + commands + suffix) + + +def get_result(*commands: str) -> subprocess.CompletedProcess[str]: + """Get the output from the given GAP commands.""" + commands = sanitize_commands(commands) + shell_commands = ["gap", "-q", "--quitonbreak", "-c", " ".join(commands)] + result = subprocess.run(shell_commands, capture_output=True, text=True) + return result diff --git a/qldpc/external/gap_test.py b/qldpc/external/gap_test.py new file mode 100644 index 00000000..a84673f2 --- /dev/null +++ b/qldpc/external/gap_test.py @@ -0,0 +1,45 @@ +"""Unit tests for gap.py + +Copyright 2023 The qLDPC Authors and Infleqtion Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from __future__ import annotations + +import subprocess +import unittest.mock + +from qldpc import external + + +def get_mock_process(stdout: str) -> subprocess.CompletedProcess[str]: + """Fake process with the given stdout.""" + return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout) + + +def test_is_installed() -> None: + """Is GAP 4 installed?""" + with unittest.mock.patch("subprocess.run", return_value=get_mock_process("\n4.12.1")): + assert external.gap.is_installed() + with unittest.mock.patch("subprocess.run", return_value=get_mock_process("")): + assert not external.gap.is_installed() + with unittest.mock.patch("subprocess.run", side_effect=Exception): + assert not external.gap.is_installed() + + +def test_get_result() -> None: + """Run GAP commands and retrieve the GAP output.""" + output = "test" + with unittest.mock.patch("subprocess.run", return_value=get_mock_process(output)): + assert external.gap.get_result().stdout == output diff --git a/qldpc/external/groups.py b/qldpc/external/groups.py index b78c08e7..b48982da 100644 --- a/qldpc/external/groups.py +++ b/qldpc/external/groups.py @@ -1,4 +1,4 @@ -"""Module for loading groups from the GAP computer algebra system +"""Module for loading groups from GroupNames or the GAP computer algebra system Copyright 2023 The qLDPC Authors and Infleqtion Inc. @@ -18,12 +18,11 @@ from __future__ import annotations import re -import subprocess import urllib.error import urllib.request -from collections.abc import Sequence import qldpc.cache +import qldpc.external.gap CACHE_NAME = "qldpc_groups" GENERATORS_LIST = list[list[tuple[int, ...]]] @@ -113,40 +112,10 @@ def get_generators_from_groupnames(group: str) -> GENERATORS_LIST | None: return generators -def gap_is_installed() -> bool: - """Is GAP 4 installed?""" - commands = ["gap", "-q", "-c", r'Print(GAPInfo.Version, "\n"); QUIT;'] - try: - result = subprocess.run(commands, capture_output=True, text=True) - return bool(re.match(r"\n4\.[0-9]+\.[0-9]+$", result.stdout)) - except Exception: - return False - - -def sanitize_gap_commands(commands: Sequence[str]) -> tuple[str, ...]: - """Sanitize GAP commands: don't format Print statements, and quit at the end.""" - stream = "__stream__" - prefix = [ - f"{stream} := OutputTextUser();", - f"SetPrintFormattingStatus({stream}, false);", - ] - suffix = ["QUIT;"] - commands = [cmd.replace("Print(", f"PrintTo({stream}, ") for cmd in commands] - return tuple(prefix + commands + suffix) - - -def get_gap_result(*commands: str) -> subprocess.CompletedProcess[str]: - """Get the output from the given GAP commands.""" - commands = sanitize_gap_commands(commands) - shell_commands = ["gap", "-q", "--quitonbreak", "-c", " ".join(commands)] - result = subprocess.run(shell_commands, capture_output=True, text=True) - return result - - def get_generators_with_gap(group: str) -> GENERATORS_LIST | None: """Retrieve GAP group generators from GAP directly.""" - if not gap_is_installed(): + if not qldpc.external.gap.is_installed(): return None # run GAP commands @@ -157,7 +126,7 @@ def get_generators_with_gap(group: str) -> GENERATORS_LIST | None: "gens := GeneratorsOfGroup(permG);", r'for gen in gens do Print(gen, "\n"); od;', ] - result = get_gap_result(*commands) + result = qldpc.external.gap.get_result(*commands) if not result.stdout.strip(): raise ValueError(f"Group not recognized by GAP: {group}") @@ -209,9 +178,9 @@ def get_generators(group: str) -> GENERATORS_LIST: @qldpc.cache.use_disk_cache(CACHE_NAME) def get_small_group_number(order: int) -> int: """Get the number of 'SmallGroup's of a given order.""" - if gap_is_installed(): + if qldpc.external.gap.is_installed(): command = f"Print(NumberSmallGroups({order}));" - return int(get_gap_result(command).stdout) + return int(qldpc.external.gap.get_result(command).stdout) # get the HTML for the page with all groups page_html = maybe_get_webpage(order) @@ -233,9 +202,9 @@ def get_small_group_structure(order: int, index: int) -> str: # try to retrieve the structure from GAP name = f"SmallGroup({order},{index})" - if gap_is_installed(): + if qldpc.external.gap.is_installed(): command = f"Print(StructureDescription({name}));" - result = get_gap_result(command) + result = qldpc.external.gap.get_result(command) structure = result.stdout.strip() if not structure: diff --git a/qldpc/external/groups_test.py b/qldpc/external/groups_test.py index 42c24ba3..eac6782c 100644 --- a/qldpc/external/groups_test.py +++ b/qldpc/external/groups_test.py @@ -113,35 +113,18 @@ def get_mock_process(stdout: str) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout) -def test_gap_is_installed() -> None: - """Is GAP 4 installed?""" - with unittest.mock.patch("subprocess.run", return_value=get_mock_process("\n4.12.1")): - assert external.groups.gap_is_installed() - with unittest.mock.patch("subprocess.run", return_value=get_mock_process("")): - assert not external.groups.gap_is_installed() - with unittest.mock.patch("subprocess.run", side_effect=Exception): - assert not external.groups.gap_is_installed() - - -def test_get_gap_result() -> None: - """Run GAP commands and retrieve the GAP output.""" - output = "test" - with unittest.mock.patch("subprocess.run", return_value=get_mock_process(output)): - assert external.groups.get_gap_result().stdout == output - - def test_get_generators_with_gap() -> None: """Retrive generators from GAP 4.""" # GAP is not installed - with unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=False): + with unittest.mock.patch("qldpc.external.gap.is_installed", return_value=False): assert external.groups.get_generators_with_gap(GROUP) is None # cannot extract cycle from string mock_process = get_mock_process("\n(1, 2a)\n") with ( - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), pytest.raises(ValueError, match="Cannot extract cycle"), ): assert external.groups.get_generators_with_gap(GROUP) is None @@ -149,8 +132,8 @@ def test_get_generators_with_gap() -> None: # group not recognized by GAP mock_process = get_mock_process("") with ( - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), pytest.raises(ValueError, match="not recognized by GAP"), ): assert external.groups.get_generators_with_gap(GROUP) is None @@ -158,8 +141,8 @@ def test_get_generators_with_gap() -> None: # everything works as expected mock_process = get_mock_process("\n(1, 2)\n") with ( - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), ): assert external.groups.get_generators_with_gap(GROUP) == GENERATORS @@ -206,7 +189,7 @@ def test_get_small_group_number() -> None: # fail to determine group number with ( unittest.mock.patch("qldpc.external.groups.maybe_get_webpage", return_value=None), - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=False), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=False), pytest.raises(ValueError, match="Cannot determine"), ): external.groups.get_small_group_number(order) @@ -214,14 +197,14 @@ def test_get_small_group_number() -> None: # retrieve from GAP mock_process = get_mock_process(str(number)) with ( - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=mock_process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=mock_process), ): assert external.groups.get_small_group_number(order) == number # retrieve from GroupNames.org with ( - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=False), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=False), unittest.mock.patch("qldpc.external.groups.maybe_get_webpage", return_value=text), ): assert external.groups.get_small_group_number(order) == number @@ -241,8 +224,8 @@ def test_get_small_group_structure() -> None: process = get_mock_process("") with ( unittest.mock.patch("qldpc.cache.get_disk_cache", return_value={}), - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=process), pytest.raises(ValueError, match="Group not recognized"), ): external.groups.get_small_group_structure(order, index) @@ -251,15 +234,15 @@ def test_get_small_group_structure() -> None: process = get_mock_process(structure) with ( unittest.mock.patch("qldpc.cache.get_disk_cache", return_value={}), - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=True), - unittest.mock.patch("qldpc.external.groups.get_gap_result", return_value=process), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=True), + unittest.mock.patch("qldpc.external.gap.get_result", return_value=process), ): assert external.groups.get_small_group_structure(order, index) == structure # GAP is not installed with ( unittest.mock.patch("qldpc.cache.get_disk_cache", return_value={}), - unittest.mock.patch("qldpc.external.groups.gap_is_installed", return_value=False), + unittest.mock.patch("qldpc.external.gap.is_installed", return_value=False), ): structure = f"SmallGroup({order},{index})" assert external.groups.get_small_group_structure(order, index) == structure