Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Factor out GAP interface #137

Merged
merged 1 commit into from
Nov 15, 2024
Merged
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
6 changes: 3 additions & 3 deletions qldpc/external/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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");',
Expand All @@ -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")
Expand Down
18 changes: 9 additions & 9 deletions qldpc/external/codes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,41 @@ 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("")

# 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("")

# 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("")

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("")
52 changes: 52 additions & 0 deletions qldpc/external/gap.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions qldpc/external/gap_test.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 8 additions & 39 deletions qldpc/external/groups.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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, ...]]]
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
49 changes: 16 additions & 33 deletions qldpc/external/groups_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,53 +113,36 @@ 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

# 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

# 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

Expand Down Expand Up @@ -206,22 +189,22 @@ 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)

# 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
Expand All @@ -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)
Expand All @@ -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