Skip to content
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
5 changes: 4 additions & 1 deletion .github/workflows/CI-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,8 @@ jobs:
if: matrix.package-manager == 'conda'
run: |
micromamba install pytest pytest-timeout
# --downstream reads CNApy's source to check the names it imports from this
# package. It needs network and tracks a branch we do not control, so it is
# limited to the quarterly scheduled run rather than gating pull requests.
- name: Test with pytest
run: pytest tests -v --medium --log-cli-level=INFO
run: pytest tests -v --medium ${{ github.event_name == 'schedule' && '--downstream' || '' }} --log-cli-level=INFO
2 changes: 1 addition & 1 deletion conda-recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

package:
name: straindesign
version: {{ version }}
version: "{{ version }}"

source:
path: ..
Expand Down
22 changes: 22 additions & 0 deletions straindesign/parse_constr.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ def lineq2list(equations, reaction_ids) -> List:
return D


def lineqlist2str(D):
"""Translates a *linear* (in)equality from the list format [lhs,sign,rhs] to a string

E.g. input: D=[{"a":3.0,"b":-1.0,"c":2.0},"<=",2.0]] is translated to: out="3.0 a - 1.0 b + 2.0 c <= 2"

Args:
D (list):
(In)equality in list form, e.g.: D=[{"a":3.0,"b":-1.0,"c":2.0},"<=",2.0]]

Returns:
(str):
A list of (in)equalities in string form

"""
if D[0]:
return linexprdict2str(D[0]) + " " + D[1] + " " + str(D[2])
elif D[1] and D[2]:
return D[1] + " " + str(D[2])
else:
return ""


def lineqlist2mat(D, reaction_ids) -> Tuple[sparse.csr_matrix, Tuple, sparse.csr_matrix, Tuple]:
"""Translates *linear* (in)equalities presented in the list of lists format to matrices

Expand Down
9 changes: 6 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
from straindesign.names import *

# ---------------------------------------------------------------------------
# Custom CLI flags for test_performance.py tiered benchmarks
# Custom CLI flags for opt-in tests: the tiered benchmarks in
# test_09_performance.py and the downstream check in test_13_public_api.py
# ---------------------------------------------------------------------------


def pytest_addoption(parser):
for name, help_text in [
("--medium", "Run iMLcore genome-scale benchmarks (~4 min total)."),
("--large", "Run iML1515 large-model benchmarks (several min/solver)."),
("--downstream", "Check the names CNApy imports against this package (needs network)."),
]:
try:
parser.addoption(name, action="store_true", default=False, help=help_text)
Expand All @@ -23,6 +25,7 @@ def pytest_configure(config):
for marker, desc in [
("medium", "genome-scale benchmark; enable with --medium"),
("large", "large-model benchmark; enable with --large"),
("downstream", "downstream consumer check; enable with --downstream"),
]:
config.addinivalue_line("markers", f"{marker}: {desc}")
# Suppress known third-party warnings
Expand All @@ -31,9 +34,9 @@ def pytest_configure(config):


def pytest_collection_modifyitems(config, items):
for flag, marker in [("--medium", "medium"), ("--large", "large")]:
for flag, marker in [("--medium", "medium"), ("--large", "large"), ("--downstream", "downstream")]:
if not config.getoption(flag, default=False):
skip = pytest.mark.skip(reason=f"pass {flag} to enable this benchmark")
skip = pytest.mark.skip(reason=f"pass {flag} to enable this test")
for item in items:
if marker in item.keywords:
item.add_marker(skip)
Expand Down
104 changes: 104 additions & 0 deletions tests/test_13_public_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Test that the public API downstream packages import stays importable.

Being unused inside this repository is not evidence that a public name is unused:
`lineqlist2str` had no caller here and was deleted as dead code, but CNApy imports
it at startup in gui_elements/strain_design_dialog.py, so the removal broke the
application. These names are re-checked here because grepping this repository
cannot see that.
"""
import ast
import importlib
import io
import tarfile
import urllib.request
import warnings

import pytest

CNAPY_TARBALL = "https://github.com/cnapy-org/CNApy/archive/refs/heads/master.tar.gz"

# Imported by CNApy; see cnapy/gui_elements/strain_design_dialog.py and siblings.
CNAPY_IMPORTS = {
"straindesign": [
"avail_solvers",
"compute_strain_designs",
"fba",
"lineq2list",
"lineqlist2str",
"linexpr2dict",
"linexprdict2str",
"plot_flux_space",
"SDModule",
"select_solver",
"yopt",
],
"straindesign.parse_constr": ["lineq2list", "linexpr2dict", "linexprdict2str"],
"straindesign.names": ["CPLEX", "GLPK", "GUROBI", "SCIP"],
"straindesign.strainDesignSolutions": ["SDSolutions"],
}


@pytest.mark.parametrize("module,names", sorted(CNAPY_IMPORTS.items()))
def test_public_names_are_importable(module, names):
"""Names that downstream packages import must remain importable."""
mod = importlib.import_module(module)
missing = [n for n in names if not hasattr(mod, n)]
assert not missing, f"{module} no longer exports: {', '.join(missing)}"


def _straindesign_names_imported_by(source):
"""Yield (module, name) for every straindesign import in a source file."""
with warnings.catch_warnings():
# Parsing someone else's source can raise SyntaxWarning for things like
# invalid escape sequences. Those are CNApy's to fix, not signal here.
warnings.simplefilter("ignore", SyntaxWarning)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.level == 0 and node.module and node.module.split(".")[0] == "straindesign":
for alias in node.names:
if alias.name != "*":
yield node.module, alias.name


@pytest.mark.downstream
def test_cnapy_master_imports_still_exist():
"""Every straindesign name CNApy imports must still be exported.

CNApy is read rather than installed: it depends on Qt, a JVM via jpype, and
CPLEX, none of which belong in this matrix. Reading the source catches names
CNApy has newly imported, which the hardcoded list above cannot.
"""
try:
with urllib.request.urlopen(CNAPY_TARBALL, timeout=120) as response:
payload = response.read()
except Exception as exc: # offline, rate-limited, or the branch moved
pytest.skip(f"could not fetch CNApy source: {exc}")

wanted = set()
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as tar:
for member in tar.getmembers():
if not member.name.endswith(".py"):
continue
handle = tar.extractfile(member)
if handle is None:
continue
wanted.update(_straindesign_names_imported_by(handle.read().decode("utf-8", "replace")))

assert wanted, "found no straindesign imports in CNApy; the parser or the URL is wrong"

missing = []
for module, name in sorted(wanted):
mod = importlib.import_module(module)
if not hasattr(mod, name):
missing.append(f"{module}.{name}")
assert not missing, ("CNApy imports names this package no longer exports: " + ", ".join(missing))


def test_lineqlist2str_formats_an_inequality():
"""lineqlist2str renders [lhs, sign, rhs] as a string, as CNApy displays it."""
from straindesign import lineqlist2str

assert lineqlist2str([{"a": 3.0, "b": -1.0}, "<=", 2.0]) == "3.0 a - 1.0 b <= 2.0"
assert lineqlist2str([{}, "<=", 2.0]) == "<= 2.0"
assert lineqlist2str([{}, "", ""]) == ""
Loading