Skip to content

Commit 77c580c

Browse files
authored
Add test to ensure dispatched functions in nxapi match networkx (#29)
* Add test to ensure dispatched functions in nxapi match networkx * Uh, I was just testing the test ;) * Require `pytest --check-structure` to run `test_match_nx.py` tests
1 parent 8684f35 commit 77c580c

File tree

9 files changed

+199
-4
lines changed

9 files changed

+199
-4
lines changed

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- name: PyTest
4343
run: |
4444
python -c 'import sys, graphblas_algorithms; assert "networkx" not in sys.modules'
45-
coverage run --branch -m pytest -v
45+
coverage run --branch -m pytest -v --check-structure
4646
coverage report
4747
NETWORKX_GRAPH_CONVERT=graphblas pytest --pyargs networkx --cov --cov-append
4848
coverage report

conftest.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pytest
2+
3+
4+
def pytest_addoption(parser):
5+
parser.addoption(
6+
"--check-structure",
7+
"--checkstructure",
8+
default=None,
9+
action="store_true",
10+
help="Check that `graphblas_algorithms.nxapi` matches networkx structure",
11+
)
12+
13+
14+
def pytest_runtest_setup(item):
15+
if "checkstructure" in item.keywords and not item.config.getoption("--check-structure"):
16+
pytest.skip("need --check-structure option to run")

graphblas_algorithms/nxapi/__init__.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,11 @@
1414
from .simple_paths import *
1515
from .smetric import *
1616
from .structuralholes import *
17-
from .tournament import *
1817
from .triads import *
18+
19+
from . import centrality
20+
from . import cluster
21+
from . import community
22+
from . import link_analysis
23+
from . import shortest_paths
24+
from . import tournament

graphblas_algorithms/nxapi/community/quality.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from graphblas_algorithms import algorithms
22
from graphblas_algorithms.classes.digraph import to_graph
33

4-
__all__ = ["intra_community_edges", "inter_community_edges"]
4+
__all__ = []
55

66

77
def intra_community_edges(G, partition):

graphblas_algorithms/nxapi/structuralholes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from graphblas_algorithms import algorithms
22
from graphblas_algorithms.classes.digraph import to_graph
33

4-
__all__ = ["mutual_weight"]
4+
__all__ = []
55

66

77
def mutual_weight(G, u, v, weight=None):

graphblas_algorithms/nxapi/tournament.py

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from graphblas_algorithms.classes.digraph import to_directed_graph
55
from graphblas_algorithms.utils import not_implemented_for
66

7+
from .simple_paths import is_simple_path as is_path # noqa
8+
79
__all__ = ["is_tournament", "score_sequence", "tournament_matrix"]
810

911

graphblas_algorithms/tests/__init__.py

Whitespace-only changes.
+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
""" Test that `graphblas.nxapi` structure matches that of networkx.
2+
3+
This currently checks the locations and names of all networkx-dispatchable functions
4+
that are implemented by `graphblas_algorithms`. It ignores names that begin with `_`.
5+
6+
The `test_dispatched_funcs_in_nxap` test below will say what to add and delete under `nxapi`.
7+
8+
We should consider removing any test here that becomes too much of a nuisance.
9+
For now, though, let's try to match and stay up-to-date with NetworkX!
10+
11+
"""
12+
import sys
13+
from collections import namedtuple
14+
15+
import pytest
16+
17+
import graphblas_algorithms as ga
18+
19+
try:
20+
import networkx as nx
21+
except ImportError:
22+
pytest.skip(
23+
"Matching networkx namespace requires networkx to be installed", allow_module_level=True
24+
)
25+
else:
26+
from networkx.classes import backends
27+
28+
29+
def isdispatched(func):
30+
"""Can this NetworkX function dispatch to other backends?"""
31+
# Haha, there should be a better way to know this
32+
registered_algorithms = backends._registered_algorithms
33+
try:
34+
return (
35+
func.__globals__.get("_registered_algorithms") is registered_algorithms
36+
and func.__module__.startswith("networkx")
37+
and func.__module__ != "networkx.classes.backends"
38+
and set(func.__code__.co_freevars) == {"func", "name"}
39+
)
40+
except Exception:
41+
return False
42+
43+
44+
def dispatchname(func):
45+
"""The dispatched name of the dispatchable NetworkX function"""
46+
# Haha, there should be a better way to get this
47+
if not isdispatched(func):
48+
raise ValueError(f"Function is not dispatched in NetworkX: {func.__name__}")
49+
index = func.__code__.co_freevars.index("name")
50+
return func.__closure__[index].cell_contents
51+
52+
53+
def fullname(func):
54+
return f"{func.__module__}.{func.__name__}"
55+
56+
57+
NameInfo = namedtuple("NameInfo", ["dispatchname", "fullname", "curpath"])
58+
59+
60+
@pytest.fixture(scope="module")
61+
def nx_info():
62+
rv = {} # {modulepath: {dispatchname: NameInfo}}
63+
for modname, module in sys.modules.items():
64+
cur = {}
65+
if not modname.startswith("networkx.") and modname != "networkx" or "tests" in modname:
66+
continue
67+
for key, val in vars(module).items():
68+
if not key.startswith("_") and isdispatched(val):
69+
dname = dispatchname(val)
70+
cur[dname] = NameInfo(dname, fullname(val), f"{modname}.{key}")
71+
if cur:
72+
rv[modname] = cur
73+
return rv
74+
75+
76+
@pytest.fixture(scope="module")
77+
def gb_info():
78+
rv = {} # {modulepath: {dispatchname: NameInfo}}
79+
from graphblas_algorithms import nxapi
80+
from graphblas_algorithms.interface import Dispatcher
81+
82+
ga_map = {
83+
fullname(val): key
84+
for key, val in vars(Dispatcher).items()
85+
if callable(val) and fullname(val).startswith("graphblas_algorithms.nxapi.")
86+
}
87+
for modname, module in sys.modules.items():
88+
cur = {}
89+
if not modname.startswith("graphblas_algorithms.nxapi") or "tests" in modname:
90+
continue
91+
for key, val in vars(module).items():
92+
try:
93+
fname = fullname(val)
94+
except Exception:
95+
continue
96+
if key.startswith("_") or fname not in ga_map:
97+
continue
98+
dname = ga_map[fname]
99+
cur[dname] = NameInfo(dname, fname, f"{modname}.{key}")
100+
if cur:
101+
rv[modname] = cur
102+
return rv
103+
104+
105+
@pytest.fixture(scope="module")
106+
def nx_names_to_info(nx_info):
107+
rv = {} # {dispatchname: {NameInfo}}
108+
for names in nx_info.values():
109+
for name, info in names.items():
110+
if name not in rv:
111+
rv[name] = set()
112+
rv[name].add(info)
113+
return rv
114+
115+
116+
@pytest.fixture(scope="module")
117+
def gb_names_to_info(gb_info):
118+
rv = {} # {dispatchname: {NameInfo}}
119+
for names in gb_info.values():
120+
for name, info in names.items():
121+
if name not in rv:
122+
rv[name] = set()
123+
rv[name].add(info)
124+
return rv
125+
126+
127+
@pytest.mark.checkstructure
128+
def test_nonempty(nx_info, gb_info, nx_names_to_info, gb_names_to_info):
129+
assert len(nx_info) > 15
130+
assert len(gb_info) > 15
131+
assert len(nx_names_to_info) > 30
132+
assert len(gb_names_to_info) > 30
133+
134+
135+
def nx_to_gb_info(info):
136+
gb = "graphblas_algorithms.nxapi"
137+
return NameInfo(
138+
info[0],
139+
info[1].replace("networkx.algorithms", gb).replace("networkx", gb),
140+
info[2].replace("networkx.algorithms", gb).replace("networkx", gb),
141+
)
142+
143+
144+
@pytest.mark.checkstructure
145+
def test_dispatched_funcs_in_nxapi(nx_names_to_info, gb_names_to_info):
146+
"""Are graphblas_algorithms functions in the correct locations in nxapi?"""
147+
failing = False
148+
for name in nx_names_to_info.keys() & gb_names_to_info.keys():
149+
nx_paths = {nx_to_gb_info(info) for info in nx_names_to_info[name]}
150+
gb_paths = gb_names_to_info[name]
151+
if nx_paths != gb_paths: # pragma: no cover
152+
failing = True
153+
if missing := (nx_paths - gb_paths):
154+
from_ = ":".join(next(iter(missing))[1].rsplit(".", 1))
155+
print(f"Add `{name}` from `{from_}` here:")
156+
for _, _, path in sorted(missing):
157+
print(" ", ":".join(path.rsplit(".", 1)))
158+
if extra := (gb_paths - nx_paths):
159+
from_ = ":".join(next(iter(extra))[1].rsplit(".", 1))
160+
print(f"Remove `{name}` from `{from_}` here:")
161+
for _, _, path in sorted(extra):
162+
print(" ", ":".join(path.rsplit(".", 1)))
163+
if failing: # pragma: no cover
164+
raise AssertionError()

setup.cfg

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ float_to_top = true
2626
default_section = THIRDPARTY
2727
known_first_party = graphblas_algorithms
2828
line_length = 100
29+
skip =
30+
graphblas_algorithms/nxapi/__init__.py
2931
3032
[coverage:run]
3133
source = graphblas_algorithms
@@ -54,3 +56,8 @@ versionfile_source = graphblas_algorithms/_version.py
5456
versionfile_build = graphblas_algorithms/_version.py
5557
tag_prefix =
5658
parentdir_prefix = graphblas_algorithms-
59+
60+
[tool:pytest]
61+
testpaths = graphblas_algorithms
62+
markers:
63+
checkstructure: Skipped unless --check-structure passed

0 commit comments

Comments
 (0)