From 6950edd5bc6ea5b15e534dbb7d5a526f33963222 Mon Sep 17 00:00:00 2001 From: Nguyen Huy Vu Date: Thu, 15 Oct 2026 23:24:23 +0700 Subject: [PATCH 1/5] Move and generalize the Express graph conversion functions Moves networkx_graph() and pgraph_graph() from planarity_networkx.py to planarity_functions.py, adds convert_to_pgraph() and convert_from_pgraph(pgraph, to_type) covering the networkx, edgelist, dictofdicts, dictofsets, and dictoflists formats, and keeps the original names working as compatibility entry points. Resolves #77 --- planarity/express/planarity_functions.py | 120 +++++++++++++++- planarity/express/planarity_networkx.py | 41 ------ tests/test_express_api_conversions.py | 172 +++++++++++++++++++++++ 3 files changed, 291 insertions(+), 42 deletions(-) create mode 100644 tests/test_express_api_conversions.py diff --git a/planarity/express/planarity_functions.py b/planarity/express/planarity_functions.py index 325c73f..bc78af2 100644 --- a/planarity/express/planarity_functions.py +++ b/planarity/express/planarity_functions.py @@ -10,7 +10,11 @@ 'ascii', 'draw', 'write', - 'mapping' + 'mapping', + 'convert_to_pgraph', + 'convert_from_pgraph', + 'pgraph_graph', + 'networkx_graph' ] @@ -228,3 +232,117 @@ def mapping(graph) -> dict[int, typing.Any]: :py:class:`~planarity.express.planarity.PGraph`. """ return planarity.PGraph(graph).mapping() + + +def convert_to_pgraph(graph): + """Builds a :py:class:`~planarity.express.planarity.PGraph` from the graph. + + Args: + graph: A graph specified in a format that may be converted to a + :py:class:`~planarity.express.planarity.PGraph`, including a + :external+networkx:py:class:`networkx.Graph`. + + Returns: + A :py:class:`~planarity.express.planarity.PGraph` representing the same + graph as the input ``graph``. + + Raises: + ValueError: if the given graph is already a + :py:class:`~planarity.express.planarity.PGraph`. + RuntimeError: if the graph couldn't be converted to a + :py:class:`~planarity.express.planarity.PGraph`. + """ + return planarity.PGraph(graph) + + +def convert_from_pgraph(pgraph, to_type): + """Builds a graph representation of ``to_type`` from a + :py:class:`~planarity.express.planarity.PGraph`. + + Args: + pgraph: A :py:class:`~planarity.express.planarity.PGraph` to convert. + to_type (str): The graph representation to build. One of: + + * ``'networkx'`` - A + :external+networkx:py:class:`networkx.Graph`; requires + ``NetworkX`` to be importable. + * ``'edgelist'`` - A list of ``(u, v)`` edge tuples. Note that + this representation cannot preserve isolated vertices. + * ``'dictofdicts'`` - A dictionary mapping each vertex to a + dictionary of its neighbors, each mapped to an empty + ``dict``. + * ``'dictofsets'`` - A dictionary mapping each vertex to the + ``set`` of its neighbors. + * ``'dictoflists'`` - A dictionary mapping each vertex to the + ``list`` of its neighbors. + + The four representations other than ``'networkx'`` are the graph + formats accepted by + :py:class:`~planarity.express.planarity.PGraph` and illustrated in + ``examples/express/graph_formats.py``. Each adjacency + representation lists every neighbor of each vertex, so each + undirected edge appears in both directions. + + Returns: + The graph in the requested representation. + + Raises: + ImportError: On failure to import ``NetworkX`` when ``to_type`` is + ``'networkx'``. + ValueError: if ``to_type`` is not one of the supported graph + representations. + """ + if to_type == 'networkx': + try: + import networkx as nx + except ImportError: + raise ImportError("NetworkX required to convert to a networkx.Graph") + graph = nx.Graph() + graph.add_nodes_from(pgraph.nodes(include_drawplanar_vertex_info=True)) + graph.add_edges_from(pgraph.edges(include_drawplanar_edge_info=True)) + return graph + + if to_type not in ('edgelist', 'dictofdicts', 'dictofsets', 'dictoflists'): + raise ValueError( + "to_type must be one of 'networkx', 'edgelist', 'dictofdicts', " + "'dictofsets', or 'dictoflists', not " + f"'{to_type}'" + ) + + nodes = pgraph.nodes() + edges = pgraph.edges() + + if to_type == 'edgelist': + return [(u, v) for u, v in edges] + + if to_type == 'dictofdicts': + graph = {node: {} for node in nodes} + for u, v in edges: + graph[u][v] = {} + graph[v][u] = {} + return graph + + if to_type == 'dictofsets': + graph = {node: set() for node in nodes} + for u, v in edges: + graph[u].add(v) + graph[v].add(u) + return graph + + graph = {node: [] for node in nodes} + for u, v in edges: + graph[u].append(v) + graph[v].append(u) + return graph + + +# This function is being preserved for backwards compatibility, but Express API +# users should call convert_to_pgraph() instead. +def pgraph_graph(graph): + return planarity.PGraph(graph) + + +# This function is being preserved for backwards compatibility, but Express API +# users should call convert_from_pgraph() instead. +def networkx_graph(pgraph): + return convert_from_pgraph(pgraph, to_type='networkx') diff --git a/planarity/express/planarity_networkx.py b/planarity/express/planarity_networkx.py index 88d16c2..bff2988 100644 --- a/planarity/express/planarity_networkx.py +++ b/planarity/express/planarity_networkx.py @@ -3,8 +3,6 @@ __all__ = [ 'kuratowski_subgraph', - 'networkx_graph', - 'pgraph_graph', ] @@ -42,42 +40,3 @@ def kuratowski_subgraph(graph): pgraph = planarity.PGraph(graph) edges = pgraph.kuratowski_edges() return nx.Graph(edges) - - -def networkx_graph(pgraph): - """Builds a :external+networkx:py:class:`networkx.Graph` from a :py:class:`~planarity.express.planarity.PGraph`. - - Args: - pgraph: A :py:class:`~planarity.express.planarity.PGraph` to convert to - a A :external+networkx:py:class:`networkx.Graph`. - - Returns: - A :external+networkx:py:class:`networkx.Graph` built from a - :py:class:`~planarity.express.planarity.PGraph`. - - Raises: - ImportError: On failure to import ``NetworkX``. - """ - try: - import networkx as nx - except ImportError: - raise ImportError("NetworkX required to convert to a networkx.Graph") - graph = nx.Graph() - graph.add_nodes_from(pgraph.nodes(include_drawplanar_vertex_info=True)) - graph.add_edges_from(pgraph.edges(include_drawplanar_edge_info=True)) - return graph - - -def pgraph_graph(graph): - """Builds a :py:class:`~planarity.express.planarity.PGraph` from the graph. - - Args: - graph: A graph specified in a format that may be converted to a - :py:class:`~planarity.express.planarity.PGraph`, including a - :external+networkx:py:class:`networkx.Graph`. - - Returns: - A :py:class:`~planarity.express.planarity.PGraph` representing the same - graph as the input ``graph``. - """ - return planarity.PGraph(graph) diff --git a/tests/test_express_api_conversions.py b/tests/test_express_api_conversions.py new file mode 100644 index 0000000..0b4531a --- /dev/null +++ b/tests/test_express_api_conversions.py @@ -0,0 +1,172 @@ +import sys + +import planarity +import pytest +from unittest import SkipTest + +# K5 is non-planar and dense enough that each supported adjacency format is +# fully populated, so conversions are exercised in both directions. +k5_edgelist = [(0, 1), (0, 2), (0, 3), (0, 4), + (1, 2), (1, 3), (1, 4), + (2, 3), (2, 4), + (3, 4)] + +k5_dictofdicts = {0: {1: {}, 2: {}, 3: {}, 4: {}}, + 1: {0: {}, 2: {}, 3: {}, 4: {}}, + 2: {0: {}, 1: {}, 3: {}, 4: {}}, + 3: {0: {}, 1: {}, 2: {}, 4: {}}, + 4: {0: {}, 1: {}, 2: {}, 3: {}}} + +k5_dictofsets = {0: {1, 2, 3, 4}, + 1: {0, 2, 3, 4}, + 2: {0, 1, 3, 4}, + 3: {0, 1, 2, 4}, + 4: {0, 1, 2, 3}} + +k5_dictoflists = {0: [1, 2, 3, 4], + 1: [0, 2, 3, 4], + 2: [0, 1, 3, 4], + 3: [0, 1, 2, 4], + 4: [0, 1, 2, 3]} + +k5_formats = { + 'edgelist': k5_edgelist, + 'dictofdicts': k5_dictofdicts, + 'dictofsets': k5_dictofsets, + 'dictoflists': k5_dictoflists, +} + +# Adjacency formats can represent an isolated vertex, while the edge list +# format cannot. +with_isolate_dictofdicts = {0: {1: {}}, 1: {0: {}}, 2: {}} +with_isolate_dictofsets = {0: {1}, 1: {0}, 2: set()} + + +def normalize(graph): + """Reduces any supported representation to a comparable adjacency map.""" + if hasattr(graph, 'items'): + return {node: frozenset(adjacency) for node, adjacency in graph.items()} + return frozenset(frozenset(edge) for edge in graph) + + +class TestConvertToPGraph: + def test_k5_formats_are_equivalent(self): + P = planarity.convert_to_pgraph(k5_edgelist) + assert P.is_planar() is False + reference_nodes = frozenset(P.nodes()) + reference_edges = frozenset(frozenset(e) for e in P.edges()) + for graph in k5_formats.values(): + Q = planarity.convert_to_pgraph(graph) + assert frozenset(Q.nodes()) == reference_nodes + assert frozenset(frozenset(e) for e in Q.edges()) == reference_edges + + def test_pgraph_graph_is_preserved(self): + for graph in k5_formats.values(): + P = planarity.pgraph_graph(graph) + Q = planarity.convert_to_pgraph(graph) + assert P.nodes() == Q.nodes() + assert normalize(P.edges()) == normalize(Q.edges()) + + def test_pgraph_input_raises(self): + P = planarity.convert_to_pgraph(k5_edgelist) + with pytest.raises(ValueError, match='not supported'): + planarity.convert_to_pgraph(P) + + +class TestConvertFromPGraph: + def test_edgelist(self): + P = planarity.convert_to_pgraph(k5_edgelist) + edges = planarity.convert_from_pgraph(P, 'edgelist') + assert isinstance(edges, list) + assert all(isinstance(e, tuple) and len(e) == 2 for e in edges) + assert normalize(edges) == normalize(k5_edgelist) + + def test_dictofdicts(self): + P = planarity.convert_to_pgraph(k5_edgelist) + assert planarity.convert_from_pgraph(P, 'dictofdicts') == k5_dictofdicts + + def test_dictofsets(self): + P = planarity.convert_to_pgraph(k5_edgelist) + assert planarity.convert_from_pgraph(P, 'dictofsets') == k5_dictofsets + + def test_dictoflists(self): + P = planarity.convert_to_pgraph(k5_edgelist) + graph = planarity.convert_from_pgraph(P, 'dictoflists') + assert all(isinstance(adjacency, list) for adjacency in graph.values()) + assert normalize(graph) == normalize(k5_dictoflists) + + def test_isolated_vertex_is_preserved(self): + P = planarity.convert_to_pgraph(with_isolate_dictofsets) + assert planarity.convert_from_pgraph(P, 'dictofsets') \ + == with_isolate_dictofsets + assert planarity.convert_from_pgraph(P, 'dictofdicts') \ + == with_isolate_dictofdicts + # The edge list representation has no way to carry the isolated + # vertex, so it only holds the single edge. + assert normalize(planarity.convert_from_pgraph(P, 'edgelist')) \ + == frozenset([frozenset([0, 1])]) + + def test_invalid_to_type_raises(self): + P = planarity.convert_to_pgraph(k5_edgelist) + with pytest.raises(ValueError, match='to_type'): + planarity.convert_from_pgraph(P, 'sparse') + + def test_round_trip(self): + for to_type, graph in k5_formats.items(): + P = planarity.convert_to_pgraph(graph) + converted = planarity.convert_from_pgraph(P, to_type) + assert normalize(converted) == normalize(graph) + + def test_original_labels_are_preserved(self): + # PGraph accepts arbitrary hashable labels; conversions must give back + # the original labels rather than the internal vertex indexes. + source = {('a', 'b'): {('c', 'd')}, + ('c', 'd'): {('a', 'b')}, + 'lonely': set()} + P = planarity.convert_to_pgraph(source) + assert planarity.convert_from_pgraph(P, 'dictofsets') == source + assert planarity.convert_from_pgraph(P, 'dictofdicts') == { + ('a', 'b'): {('c', 'd'): {}}, + ('c', 'd'): {('a', 'b'): {}}, + 'lonely': {}} + assert normalize(planarity.convert_from_pgraph(P, 'dictoflists')) \ + == normalize(source) + assert normalize(planarity.convert_from_pgraph(P, 'edgelist')) \ + == frozenset([frozenset([('a', 'b'), ('c', 'd')])]) + + def test_missing_networkx_error_is_preserved(self, monkeypatch): + P = planarity.convert_to_pgraph([(0, 1)]) + # A ``None`` entry in sys.modules makes ``import networkx`` fail, which + # exercises the preserved error message without uninstalling anything. + monkeypatch.setitem(sys.modules, 'networkx', None) + with pytest.raises(ImportError, match='NetworkX required'): + planarity.convert_from_pgraph(P, 'networkx') + with pytest.raises(ImportError, match='NetworkX required'): + planarity.networkx_graph(P) + + +class TestConvertToNetworkX: + @classmethod + def setup_class(self): + try: + global nx + import networkx as nx + except ImportError: + raise SkipTest('networkx not available.') + + def test_networkx_and_preserved_alias_agree(self): + G = nx.Graph() + G.add_edge(100, 200) + G.add_edge(200, 300) + G.add_node(1000) + P = planarity.convert_to_pgraph(G) + H = planarity.convert_from_pgraph(P, 'networkx') + assert isinstance(H, nx.Graph) + assert frozenset(H.nodes()) == frozenset(G.nodes()) + assert normalize(H.edges()) == normalize(G.edges()) + # networkx_graph() is preserved and delegates to the generalized + # converter, so both entry points must produce the same graph. + K = planarity.networkx_graph(P) + assert dict(K.nodes(data=True)) == dict(H.nodes(data=True)) + assert sorted(sorted(e) for e in K.edges()) \ + == sorted(sorted(e) for e in H.edges()) From e81b309a1a114396dd9fa2b7f2dfdcdd8e334f7c Mon Sep 17 00:00:00 2001 From: Nguyen Huy Vu Date: Fri, 16 Oct 2026 08:25:29 +0700 Subject: [PATCH 2/5] Cover DrawPlanar data in the NetworkX conversion test convert_from_pgraph() forwards the DrawPlanar vertex and edge info into the networkx.Graph, matching networkx_graph()'s behavior before the move. The existing tests compare two empty-attribute graphs, so they cannot catch a dropped include_drawplanar flag. --- tests/test_express_api_conversions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_express_api_conversions.py b/tests/test_express_api_conversions.py index 0b4531a..15908b1 100644 --- a/tests/test_express_api_conversions.py +++ b/tests/test_express_api_conversions.py @@ -170,3 +170,21 @@ def test_networkx_and_preserved_alias_agree(self): assert dict(K.nodes(data=True)) == dict(H.nodes(data=True)) assert sorted(sorted(e) for e in K.edges()) \ == sorted(sorted(e) for e in H.edges()) + + def test_drawplanar_info_is_preserved(self): + # Drawing data only exists once the graph has been embedded, so the + # equality check above cannot see a dropped include_drawplanar flag. + G = nx.path_graph(4) + P = planarity.convert_to_pgraph(G) + P.embed_drawplanar() + expected_nodes = dict(P.nodes(include_drawplanar_vertex_info=True)) + expected_edges = { + frozenset(e[:2]): e[2] + for e in P.edges(include_drawplanar_edge_info=True)} + assert any(expected_nodes.values()) + assert any(expected_edges.values()) + for H in (planarity.convert_from_pgraph(P, 'networkx'), + planarity.networkx_graph(P)): + assert dict(H.nodes(data=True)) == expected_nodes + assert {frozenset(e[:2]): e[2] for e in H.edges(data=True)} \ + == expected_edges From 69397e190b6e703aba1efb122f4cc573eab0c622 Mon Sep 17 00:00:00 2001 From: "Wanda B.K. Boyer" Date: Tue, 20 Oct 2026 18:11:44 -0700 Subject: [PATCH 3/5] Minor fixes to docstrings and exception handling. * fixed intersphinx syntax and description of implementation details in `ascii()` docstring (missed in prior passes), * added `# pylint: disable=import-outside-toplevel` to disable `pylint` complaints in `kuratowski_subgraph()` and `convert_from_pgraph()` * updated `ImportError` exception handling to re-raise `networkx_import_error` in `kuratowski_subgraph()` and `convert_from_pgraph()`, * restored docstrings for `pgraph_graph() and `networkx_graph()` to include deprecation warnings with instructions for new functions to use, * added `warnings.warn(category=DeprecationWarning)` to `pgraph_graph() and `networkx_graph()` functions. --- planarity/express/planarity_functions.py | 71 ++++++++++++++++++++---- planarity/express/planarity_networkx.py | 10 ++-- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/planarity/express/planarity_functions.py b/planarity/express/planarity_functions.py index c97b87a..dba7218 100644 --- a/planarity/express/planarity_functions.py +++ b/planarity/express/planarity_functions.py @@ -16,7 +16,7 @@ 'convert_from_pgraph', 'pgraph_graph', 'networkx_graph' - ] +] def is_planar(graph): @@ -82,14 +82,14 @@ def ascii(graph, outfileName=None) -> str | None: """[DEPRECATED] Produces an ASCII rendition of the graph, if it is planar. .. version-deprecated:: 1.1.0 - Use :py:meth:`~planarity.express.planarity_functions.draw` with + Use :py:func:`~planarity.express.planarity_functions.draw` with ``output_type='text/plain'`` and ``label_type='index'`` instead. Refer to :external+python:ref:`warning-suppress` to suppress the :external+python:py:func:`warnings.warn` function. - Constructs a :py:class:`~planarity.express.planarity.PGraph` and calls - :py:meth:`~planarity.express.planarity_functions.draw` with - ``output_type='text/plain'`` and ``label_type='index'``. + Calls :py:func:`~planarity.express.planarity_functions.draw` with the + given ``outfileName``, ``output_type='text/plain'``, and + ``label_type='index'``. Args: graph: A graph specified in a format that may be converted to a @@ -342,9 +342,11 @@ def convert_from_pgraph(pgraph, to_type): """ if to_type == 'networkx': try: - import networkx as nx - except ImportError: - raise ImportError("NetworkX required to convert to a networkx.Graph") + import networkx as nx # pylint: disable=import-outside-toplevel + except ImportError as networkx_import_error: + raise ImportError( + "NetworkX required to convert to a networkx.Graph" + ) from networkx_import_error graph = nx.Graph() graph.add_nodes_from(pgraph.nodes(include_drawplanar_vertex_info=True)) graph.add_edges_from(pgraph.edges(include_drawplanar_edge_info=True)) @@ -384,13 +386,58 @@ def convert_from_pgraph(pgraph, to_type): return graph -# This function is being preserved for backwards compatibility, but Express API -# users should call convert_to_pgraph() instead. def pgraph_graph(graph): + """[DEPRECATED] Builds a :py:class:`~planarity.express.planarity.PGraph` from the graph. + + .. version-deprecated:: 1.1.0 + Use :py:func:`~planarity.express.planarity_functions.convert_to_pgraph` + instead. + Refer to :external+python:ref:`warning-suppress` to suppress the + :external+python:py:func:`warnings.warn` function. + + Args: + graph: A graph specified in a format that may be converted to a + :py:class:`~planarity.express.planarity.PGraph`, including a + :external+networkx:py:class:`networkx.Graph`. + + Returns: + A :py:class:`~planarity.express.planarity.PGraph` representing the same + graph as the input ``graph``. + """ + warnings.warn( + "The Express API's pgraph_graph() function is deprecated; please call " + "convert_to_pgraph() instead. Please refer to the Python warnings docs " + "for how to suppress this DeprecationWarning.", + category=DeprecationWarning + ) return planarity.PGraph(graph) -# This function is being preserved for backwards compatibility, but Express API -# users should call convert_from_pgraph() instead. def networkx_graph(pgraph): + """[DEPRECATED] Builds a :external+networkx:py:class:`networkx.Graph` from a :py:class:`~planarity.express.planarity.PGraph`. + + .. version-deprecated:: 1.1.0 + Use :py:func:`~planarity.express.planarity_functions.convert_from_pgraph` + with ``to_type='networkx'`` instead. + Refer to :external+python:ref:`warning-suppress` to suppress the + :external+python:py:func:`warnings.warn` function. + + Args: + pgraph: A :py:class:`~planarity.express.planarity.PGraph` to convert to + a A :external+networkx:py:class:`networkx.Graph`. + + Returns: + A :external+networkx:py:class:`networkx.Graph` built from a + :py:class:`~planarity.express.planarity.PGraph`. + + Raises: + ImportError: On failure to import ``NetworkX``. + """ + warnings.warn( + "The Express API's networkx_graph() function is deprecated; please " + "call convert_from_pgraph() with to_type='networkx' instead. Please " + "refer to the Python warnings docs for how to suppress this " + "DeprecationWarning.", + category=DeprecationWarning + ) return convert_from_pgraph(pgraph, to_type='networkx') diff --git a/planarity/express/planarity_networkx.py b/planarity/express/planarity_networkx.py index bff2988..8abc789 100644 --- a/planarity/express/planarity_networkx.py +++ b/planarity/express/planarity_networkx.py @@ -3,7 +3,7 @@ __all__ = [ 'kuratowski_subgraph', - ] +] def kuratowski_subgraph(graph): @@ -33,9 +33,11 @@ def kuratowski_subgraph(graph): ImportError: On failure to import ``NetworkX``. """ try: - import networkx as nx - except ImportError: - raise ImportError("NetworkX required for kuratowski_subgraph()") + import networkx as nx # pylint: disable=import-outside-toplevel + except ImportError as networkx_import_error: + raise ImportError( + "NetworkX required for kuratowski_subgraph()" + ) from networkx_import_error pgraph = planarity.PGraph(graph) edges = pgraph.kuratowski_edges() From 3caf4060b038ab02444b87e3f54218be530dbb8b Mon Sep 17 00:00:00 2001 From: Nguyen Huy Vu Date: Wed, 21 Oct 2026 23:59:17 +0700 Subject: [PATCH 4/5] Address conversion test review feedback --- tests/test_express_api_conversions.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_express_api_conversions.py b/tests/test_express_api_conversions.py index 15908b1..a5a9500 100644 --- a/tests/test_express_api_conversions.py +++ b/tests/test_express_api_conversions.py @@ -1,8 +1,9 @@ import sys +from unittest import SkipTest -import planarity import pytest -from unittest import SkipTest + +import planarity # K5 is non-planar and dense enough that each supported adjacency format is # fully populated, so conversions are exercised in both directions. @@ -40,6 +41,7 @@ # format cannot. with_isolate_dictofdicts = {0: {1: {}}, 1: {0: {}}, 2: {}} with_isolate_dictofsets = {0: {1}, 1: {0}, 2: set()} +with_isolate_dictoflists = {0: [1], 1: [0], 2: []} def normalize(graph): @@ -101,6 +103,8 @@ def test_isolated_vertex_is_preserved(self): == with_isolate_dictofsets assert planarity.convert_from_pgraph(P, 'dictofdicts') \ == with_isolate_dictofdicts + assert planarity.convert_from_pgraph(P, 'dictoflists') \ + == with_isolate_dictoflists # The edge list representation has no way to carry the isolated # vertex, so it only holds the single edge. assert normalize(planarity.convert_from_pgraph(P, 'edgelist')) \ @@ -147,12 +151,12 @@ def test_missing_networkx_error_is_preserved(self, monkeypatch): class TestConvertToNetworkX: @classmethod - def setup_class(self): + def setup_class(cls): try: - global nx - import networkx as nx - except ImportError: - raise SkipTest('networkx not available.') + global nx # pylint: disable=global-statement + import networkx as nx # pylint: disable=import-outside-toplevel + except ImportError as networkx_import_error: + raise SkipTest('networkx not available.') from networkx_import_error def test_networkx_and_preserved_alias_agree(self): G = nx.Graph() From 21ea12a6cc03e3d844b11191164cea1b3b4781df Mon Sep 17 00:00:00 2001 From: "Wanda B.K. Boyer" Date: Wed, 21 Oct 2026 13:57:22 -0700 Subject: [PATCH 5/5] Using new pattern for how to skip tests based on whether or not explicit dependencies such as networkx, matplotlib, or PIL (pillow) are installed in the test environment. `@pytest.mark.skipif()` can be used to decorate individual tests or a class of tests so that these skips appear in the `pytest -vvv` output. The alternative, `pytest.importorskip()`, will cause the entire test module to be skipped, and to only report the skips during the *collection* phase rather than when running *only if* you run `pytest` using flags `-rs`) --- tests/test_express_api_conversions.py | 21 +- tests/test_express_api_draw.py | 523 ++++++++++--------- tests/test_express_api_draw_documentation.py | 11 +- tests/test_express_api_draw_labels.py | 177 ++++--- tests/test_express_api_networkx.py | 29 +- 5 files changed, 403 insertions(+), 358 deletions(-) diff --git a/tests/test_express_api_conversions.py b/tests/test_express_api_conversions.py index a5a9500..fad2267 100644 --- a/tests/test_express_api_conversions.py +++ b/tests/test_express_api_conversions.py @@ -1,10 +1,16 @@ import sys -from unittest import SkipTest import pytest +try: + import networkx as nx + TEST_ENV_HAS_NETWORKX = True +except ImportError: + TEST_ENV_HAS_NETWORKX = False + import planarity + # K5 is non-planar and dense enough that each supported adjacency format is # fully populated, so conversions are exercised in both directions. k5_edgelist = [(0, 1), (0, 2), (0, 3), (0, 4), @@ -148,16 +154,11 @@ def test_missing_networkx_error_is_preserved(self, monkeypatch): with pytest.raises(ImportError, match='NetworkX required'): planarity.networkx_graph(P) - +@pytest.mark.skipif( + not TEST_ENV_HAS_NETWORKX, + reason="networkx is not installed" +) class TestConvertToNetworkX: - @classmethod - def setup_class(cls): - try: - global nx # pylint: disable=global-statement - import networkx as nx # pylint: disable=import-outside-toplevel - except ImportError as networkx_import_error: - raise SkipTest('networkx not available.') from networkx_import_error - def test_networkx_and_preserved_alias_agree(self): G = nx.Graph() G.add_edge(100, 200) diff --git a/tests/test_express_api_draw.py b/tests/test_express_api_draw.py index 8879b92..66b7c73 100644 --- a/tests/test_express_api_draw.py +++ b/tests/test_express_api_draw.py @@ -1,265 +1,290 @@ """Exercise color controls on real artists and rendered PNGs.""" - -import matplotlib - -matplotlib.use('Agg') - -import matplotlib.pyplot as plt -from matplotlib.collections import LineCollection, PatchCollection -from matplotlib.colors import to_rgba -from PIL import Image import pytest -import planarity - - -EDGES = [('a', 'c'), ('a', 'd'), ('a', 'e'), ('b', 'c'), ('b', 'd'), - ('b', 'e'), ('c', 'd'), ('c', 'e'), ('d', 'e')] - - -@pytest.fixture(autouse=True) -def isolated_figures(): - with matplotlib.rc_context(): - yield - plt.close('all') - - -@pytest.fixture(params=['function', 'method']) -def draw(request): - if request.param == 'function': - return lambda **kwargs: planarity.draw(EDGES, **kwargs) - return lambda **kwargs: planarity.PGraph(EDGES).draw(**kwargs) - - -@pytest.mark.parametrize('option,color', [ - ('facecolor', '#abcdef'), - ('vertex_facecolor', 'gold'), - ('vertex_bordercolor', (0.3, 0.2, 0.8)), - ('vertex_label_facecolor', 'lightgreen'), - ('vertex_label_bordercolor', '#aabbcc'), - ('edge_linecolor', 'magenta'), -]) -def test_individual_colors_reach_artists(draw, option, color): - draw(**{option: color}) - ax = plt.gca() - if option == 'facecolor': - actual = [plt.gcf().get_facecolor()] - elif option.startswith('vertex_label_'): - method = ('get_facecolor' if option.endswith('facecolor') - else 'get_edgecolor') - actual = [getattr(text.get_bbox_patch(), method)() for text in ax.texts] - assert len(actual) == 5 - elif option.startswith('vertex_'): - vertices = next(c for c in ax.collections if isinstance(c, PatchCollection)) - actual = (vertices.get_facecolors() if option.endswith('facecolor') - else vertices.get_edgecolors()) - else: - actual = [c.get_colors()[0] for c in ax.collections - if isinstance(c, LineCollection)] - assert len(actual) == 9 - assert len(actual) > 0 - for rgba in actual: - assert tuple(rgba) == pytest.approx(to_rgba(color)) - - -@pytest.mark.parametrize('transparent', [False, True]) -def test_png_background_and_transparency(draw, tmp_path, transparent): - output = tmp_path / 'colors.png' - # A user's savefig style must not override an explicit draw() facecolor. - matplotlib.rcParams['savefig.facecolor'] = 'red' - draw( - outfileName=str(output), - figsize=(4, 3), - dpi=100, - facecolor='#abcdef', - transparent=transparent, - ) - with Image.open(output) as image: - # The tight bounding box (Issue #100) crops the saved image to the - # drawing plus pad_inches, so it is strictly smaller than the - # nominal figsize canvas at the given dpi. - width, height = image.size - assert width == 400 - assert height == 300 - rgba = image.convert('RGBA') - if transparent: - assert rgba.getpixel((0, 0))[3] == 0 - assert rgba.getextrema()[3][1] == 255 # Graph remains visible. - else: - assert rgba.getpixel((0, 0)) == (171, 205, 239, 255) - assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#abcdef')) - - -def test_color_controls_combine_without_labels(draw, tmp_path): - draw( - outfileName=str(tmp_path / 'no-labels.png'), - labels=False, - facecolor='white', - vertex_facecolor='yellow', - vertex_bordercolor='black', - vertex_label_facecolor='pink', - vertex_label_bordercolor='green', - edge_linecolor='red', - transparent=True, - ) - assert not plt.gca().texts - vertices = next(c for c in plt.gca().collections if isinstance(c, PatchCollection)) - assert tuple(vertices.get_facecolors()[0]) == pytest.approx(to_rgba('yellow')) - assert tuple(vertices.get_edgecolors()[0]) == pytest.approx(to_rgba('black')) - - -def test_default_background_resets_after_custom_draw(draw): - draw(facecolor='red') - draw() - assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#ffffff')) - for text in plt.gca().texts: - assert text.get_bbox_patch().get_facecolor() == to_rgba('white') - assert text.get_bbox_patch().get_edgecolor() == to_rgba('black') - - -def test_existing_figure_options(draw): - draw(figsize=(4, 3), dpi=120) - assert tuple(plt.gcf().get_size_inches()) == pytest.approx((4, 3)) - assert plt.gcf().dpi == 120 - - -def test_unknown_keyword_still_rejected(draw): - with pytest.raises(ValueError, match='not a supported draw'): - draw(vertex_colour='red') - -def test_invalid_color_rejected(draw): - with pytest.raises(ValueError, match='not-a-color'): - draw(vertex_facecolor='not-a-color') +try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from matplotlib.collections import LineCollection, PatchCollection + from matplotlib.colors import to_rgba + TEST_ENV_HAS_MATPLOTLIB = True +except ImportError as matplotlib_import_error: + TEST_ENV_HAS_MATPLOTLIB = False +try: + from PIL import Image + TEST_ENV_HAS_PILLOW = True +except ImportError as pillow_import_error: + TEST_ENV_HAS_PILLOW = False -# draw() defaults the figure dpi to 100, so pad_inches of padding shows up -# in the saved image as pad_inches * 100 pixels of background on each side. -DEFAULT_DPI = 100 - -# draw() also defaults the figure facecolor to white, and the padding it -# leaves around the drawing has that same color, so a pixel counts as -# background when each channel is close to white. The slack keeps the -# anti-aliased pixels that blend background into ink from counting as ink. -BACKGROUND = (255, 255, 255) -BACKGROUND_SLACK = 8 - - -def _background_run_length(image, start, step): - """Walks from the start pixel in the step direction, counting how many - background pixels are seen before the first non-background pixel. - - Fails the test if the walk reaches the far side of the image without - meeting any ink, since then the padding is not being measured against - the drawing at all. - """ - x, y = start - run = 0 - while 0 <= x < image.width and 0 <= y < image.height: - pixel = image.getpixel((x, y)) - if max(abs(pixel[i] - BACKGROUND[i]) for i in range(3)) > BACKGROUND_SLACK: - return run - run += 1 - x, y = x + step[0], y + step[1] - raise AssertionError( - f'walked from {start} across the image without finding the drawing' - ) - +import planarity -def _assert_pad_inches(draw, tmp_path, pad_inches): - output = tmp_path / f'pad-{pad_inches}.png' - # EDGES is K5 minus an edge, drawn here on the custom 4x3 figure. The - # padding has to come out right on a caller-chosen figure size, not - # only on the default one. - draw( - outfileName=str(output), - figsize=(4, 3), - pad_inches=pad_inches, - ) - with Image.open(output) as saved: - image = saved.convert('RGBA') - width, height = image.size - # The padding is only meaningful in pixels if the saved image really - # is the requested figsize at the default dpi. - assert (width, height) == (4 * DEFAULT_DPI, 3 * DEFAULT_DPI) - expected = round(pad_inches * DEFAULT_DPI) - # From each starting point below, walk straight into the image and - # expect expected pixels of background, then a pixel of the drawing. - # Three sides start at the midpoints of the image edges. The right - # side instead starts a quarter of the way down: the rightmost ink - # in this drawing is a vertical edge line drawn a little to the - # right of the rightmost vertices, and the line spans only part of - # the image height. Below its lower end the rightmost ink is a - # vertex box that ends slightly left of the line, so a walk there - # would overcount the background. A quarter of the way down is - # safely within the line at every pad_inches value used here. - probes = [ - ('top', (width // 2, 0), (0, 1)), - ('bottom', (width // 2, height - 1), (0, -1)), - ('left', (0, height // 2), (1, 0)), - ('right', (width - 1, height // 4), (-1, 0)), +@pytest.mark.skipif( + not TEST_ENV_HAS_MATPLOTLIB, + reason="matplotlib is not installed.", +) +@pytest.mark.skipif( + not TEST_ENV_HAS_PILLOW, + reason="pillow is not installed.", +) +class TestPlanarityExpressDraw: + @pytest.fixture(autouse=True) + def isolated_figures(self): + with matplotlib.rc_context(): + yield + plt.close('all') + + @pytest.fixture + def edges(self): + # Note the list of a fixture since this list is mutable. + return [ + ('a', 'c'), ('a', 'd'), ('a', 'e'), ('b', 'c'), ('b', 'd'), + ('b', 'e'), ('c', 'd'), ('c', 'e'), ('d', 'e'), ] - for side, start, step in probes: - run = _background_run_length(image, start, step) - # The measured run can deviate from pad_inches * dpi by a pixel - # or two: anti-aliasing blends the outermost drawing pixels - # into the background, and when vertices are drawn with a - # border (vertex_bordercolor) the border widens them, which - # measured one pixel short of the expected run. Two pixels of - # slack absorbs that, while still failing if the padding is off - # by a whole step between the tested values (0.1 inch = 10 - # pixels at the default dpi). - assert abs(run - expected) <= 2, (side, run, expected) - -def test_draw_pad_inches_controls_output_padding(draw, tmp_path): - for pad_inches in (0.0, 0.1, 0.5): - _assert_pad_inches(draw, tmp_path, pad_inches) - - -def test_draw_text(): - graph = planarity.PGraph(([1, 2],)) - assert ( - graph.draw( - output_type='text/plain', - label_type='index', - ) == '1\n|\n2\n \n' - ) - - -def test_explicit_image_type_preserves_default_png(draw, tmp_path): - default_output = tmp_path / 'default.png' - explicit_output = tmp_path / 'explicit.png' - assert draw(outfileName=str(default_output)) is None - assert ( + @pytest.fixture(params=['function', 'method']) + def draw(self, request, edges): + if request.param == 'function': + return lambda **kwargs: planarity.draw(edges, **kwargs) + return lambda **kwargs: planarity.PGraph(edges).draw(**kwargs) + + @pytest.mark.parametrize('option,color', [ + ('facecolor', '#abcdef'), + ('vertex_facecolor', 'gold'), + ('vertex_bordercolor', (0.3, 0.2, 0.8)), + ('vertex_label_facecolor', 'lightgreen'), + ('vertex_label_bordercolor', '#aabbcc'), + ('edge_linecolor', 'magenta'), + ]) + def test_individual_colors_reach_artists(self, draw, option, color): + draw(**{option: color}) + ax = plt.gca() + if option == 'facecolor': + actual = [plt.gcf().get_facecolor()] + elif option.startswith('vertex_label_'): + method = ('get_facecolor' if option.endswith('facecolor') + else 'get_edgecolor') + actual = [getattr(text.get_bbox_patch(), method)() for text in ax.texts] + assert len(actual) == 5 + elif option.startswith('vertex_'): + vertices = next( + c for c in ax.collections if isinstance(c, PatchCollection) + ) + actual = (vertices.get_facecolors() if option.endswith('facecolor') + else vertices.get_edgecolors()) + else: + actual = [c.get_colors()[0] for c in ax.collections + if isinstance(c, LineCollection)] + assert len(actual) == 9 + assert len(actual) > 0 + for rgba in actual: + assert tuple(rgba) == pytest.approx(to_rgba(color)) + + @pytest.mark.parametrize('transparent', [False, True]) + def test_png_background_and_transparency(self, draw, tmp_path, transparent): + output = tmp_path / 'colors.png' + # A user's savefig style must not override an explicit draw() facecolor. + matplotlib.rcParams['savefig.facecolor'] = 'red' draw( - outfileName=str(explicit_output), - output_type='image/png', - ) is None - ) - with Image.open(default_output) as default, Image.open(explicit_output) as explicit: - assert explicit.size == default.size - assert explicit.convert('RGBA').tobytes() == default.convert('RGBA').tobytes() - + outfileName=str(output), + figsize=(4, 3), + dpi=100, + facecolor='#abcdef', + transparent=transparent, + ) + with Image.open(output) as image: + # The tight bounding box (Issue #100) crops the saved image to the + # drawing plus pad_inches, so it is strictly smaller than the + # nominal figsize canvas at the given dpi. + width, height = image.size + assert width == 400 + assert height == 300 + rgba = image.convert('RGBA') + if transparent: + assert rgba.getpixel((0, 0))[3] == 0 + assert rgba.getextrema()[3][1] == 255 # Graph remains visible. + else: + assert rgba.getpixel((0, 0)) == (171, 205, 239, 255) + assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#abcdef')) + + def test_color_controls_combine_without_labels(self, draw, tmp_path): + draw( + outfileName=str(tmp_path / 'no-labels.png'), + labels=False, + facecolor='white', + vertex_facecolor='yellow', + vertex_bordercolor='black', + vertex_label_facecolor='pink', + vertex_label_bordercolor='green', + edge_linecolor='red', + transparent=True, + ) + assert not plt.gca().texts + vertices = next( + c for c in plt.gca().collections if isinstance(c, PatchCollection) + ) + assert ( + tuple(vertices.get_facecolors()[0]) == + pytest.approx(to_rgba('yellow')) + ) + assert ( + tuple(vertices.get_edgecolors()[0]) == + pytest.approx(to_rgba('black')) + ) -def test_graph_can_switch_between_text_and_image(tmp_path): - graph = planarity.PGraph(EDGES) - text = graph.draw(output_type='text/plain', label_type='index') - output = tmp_path / 'graph.png' - assert graph.draw(outfileName=str(output)) is None - with Image.open(output) as image: - assert image.format == 'PNG' - assert graph.draw(output_type='text/plain', label_type='index') == text + def test_default_background_resets_after_custom_draw(self, draw): + draw(facecolor='red') + draw() + assert plt.gcf().get_facecolor() == pytest.approx(to_rgba('#ffffff')) + for text in plt.gca().texts: + assert text.get_bbox_patch().get_facecolor() == to_rgba('white') + assert text.get_bbox_patch().get_edgecolor() == to_rgba('black') + + def test_existing_figure_options(self, draw): + draw(figsize=(4, 3), dpi=120) + assert tuple(plt.gcf().get_size_inches()) == pytest.approx((4, 3)) + assert plt.gcf().dpi == 120 + + def test_unknown_keyword_still_rejected(self, draw): + with pytest.raises(ValueError, match='not a supported draw'): + draw(vertex_colour='red') + + def test_invalid_color_rejected(self, draw): + with pytest.raises(ValueError, match='not-a-color'): + draw(vertex_facecolor='not-a-color') + + def _background_run_length(self, image, start, step): + """Walks from the start pixel in the step direction, counting how many + background pixels are seen before the first non-background pixel. + + Fails the test if the walk reaches the far side of the image without + meeting any ink, since then the padding is not being measured against + the drawing at all. + """ + # draw() also defaults the figure facecolor to white, and the padding it + # leaves around the drawing has that same color, so a pixel counts as + # background when each channel is close to white. The slack keeps the + # anti-aliased pixels that blend background into ink from counting as ink. + background = (255, 255, 255) + background_slack = 8 + + x, y = start + run = 0 + while 0 <= x < image.width and 0 <= y < image.height: + pixel = image.getpixel((x, y)) + if ( + max(abs(pixel[i] - background[i]) for i in range(3)) > + background_slack + ): + return run + run += 1 + x, y = x + step[0], y + step[1] + raise AssertionError( + f'walked from {start} across the image without finding the drawing' + ) + def _assert_pad_inches(self, draw, tmp_path, pad_inches): + # draw() defaults the figure dpi to 100, so pad_inches of padding shows + # up in the saved image as pad_inches * 100 pixels of background on each + # side. + default_dpi = 100 -def test_draw_rejects_null_byte_in_outfileName(draw, tmp_path): - output = tmp_path / 'must-not-overwrite.txt' - output.write_text('keep this') - with pytest.raises(ValueError, match='null'): + output = tmp_path / f'pad-{pad_inches}.png' + # EDGES is K5 minus an edge, drawn here on the custom 4x3 figure. The + # padding has to come out right on a caller-chosen figure size, not + # only on the default one. draw( - outfileName=str(output) + '\0suffix', - output_type='text/plain', - label_type='index', + outfileName=str(output), + figsize=(4, 3), + pad_inches=pad_inches, + ) + with Image.open(output) as saved: + image = saved.convert('RGBA') + width, height = image.size + # The padding is only meaningful in pixels if the saved image really + # is the requested figsize at the default dpi. + assert (width, height) == (4 * default_dpi, 3 * default_dpi) + + expected = round(pad_inches * default_dpi) + # From each starting point below, walk straight into the image and + # expect expected pixels of background, then a pixel of the drawing. + # Three sides start at the midpoints of the image edges. The right + # side instead starts a quarter of the way down: the rightmost ink + # in this drawing is a vertical edge line drawn a little to the + # right of the rightmost vertices, and the line spans only part of + # the image height. Below its lower end the rightmost ink is a + # vertex box that ends slightly left of the line, so a walk there + # would overcount the background. A quarter of the way down is + # safely within the line at every pad_inches value used here. + probes = [ + ('top', (width // 2, 0), (0, 1)), + ('bottom', (width // 2, height - 1), (0, -1)), + ('left', (0, height // 2), (1, 0)), + ('right', (width - 1, height // 4), (-1, 0)), + ] + for side, start, step in probes: + run = self._background_run_length(image, start, step) + # The measured run can deviate from pad_inches * dpi by a pixel + # or two: anti-aliasing blends the outermost drawing pixels + # into the background, and when vertices are drawn with a + # border (vertex_bordercolor) the border widens them, which + # measured one pixel short of the expected run. Two pixels of + # slack absorbs that, while still failing if the padding is off + # by a whole step between the tested values (0.1 inch = 10 + # pixels at the default dpi). + assert abs(run - expected) <= 2, (side, run, expected) + + def test_draw_pad_inches_controls_output_padding(self, draw, tmp_path): + for pad_inches in (0.0, 0.1, 0.5): + self._assert_pad_inches(draw, tmp_path, pad_inches) + + def test_draw_text(self): + graph = planarity.PGraph(([1, 2],)) + assert ( + graph.draw( + output_type='text/plain', + label_type='index', + ) == '1\n|\n2\n \n' + ) + + def test_explicit_image_type_preserves_default_png(self, draw, tmp_path): + default_output = tmp_path / 'default.png' + explicit_output = tmp_path / 'explicit.png' + assert draw(outfileName=str(default_output)) is None + assert ( + draw( + outfileName=str(explicit_output), + output_type='image/png', + ) is None ) - assert output.read_text() == 'keep this' + with ( + Image.open(default_output) as default, + Image.open(explicit_output) as explicit + ): + assert explicit.size == default.size + assert ( + explicit.convert('RGBA').tobytes() == + default.convert('RGBA').tobytes() + ) + + def test_graph_can_switch_between_text_and_image(self, tmp_path, edges): + graph = planarity.PGraph(edges) + text = graph.draw(output_type='text/plain', label_type='index') + output = tmp_path / 'graph.png' + assert graph.draw(outfileName=str(output)) is None + with Image.open(output) as image: + assert image.format == 'PNG' + assert graph.draw(output_type='text/plain', label_type='index') == text + + def test_draw_rejects_null_byte_in_outfileName(self, draw, tmp_path): + output = tmp_path / 'must-not-overwrite.txt' + output.write_text('keep this') + with pytest.raises(ValueError, match='null'): + draw( + outfileName=str(output) + '\0suffix', + output_type='text/plain', + label_type='index', + ) + assert output.read_text() == 'keep this' diff --git a/tests/test_express_api_draw_documentation.py b/tests/test_express_api_draw_documentation.py index d7d52af..faf361a 100644 --- a/tests/test_express_api_draw_documentation.py +++ b/tests/test_express_api_draw_documentation.py @@ -4,9 +4,16 @@ from pathlib import Path import pytest -from sphinx.application import Sphinx - +try: + from sphinx.application import Sphinx + TEST_ENV_HAS_SPHINX = True +except ImportError as sphinx_import_error: + TEST_ENV_HAS_SPHINX = False +@pytest.mark.skipif( + not TEST_ENV_HAS_SPHINX, + reason="sphinx is not installed in the test environment." +) # Sphinx 9's own Napoleon extension still uses this deprecated options API. @pytest.mark.filterwarnings( 'ignore:The mapping interface for autodoc options objects is deprecated' diff --git a/tests/test_express_api_draw_labels.py b/tests/test_express_api_draw_labels.py index d29054e..7c87582 100644 --- a/tests/test_express_api_draw_labels.py +++ b/tests/test_express_api_draw_labels.py @@ -1,92 +1,95 @@ """Exercise label truncation against the rendered vertex rectangles.""" - -import matplotlib - -matplotlib.use('Agg') - -import matplotlib.pyplot as plt import pytest -import planarity - - -@pytest.fixture(autouse=True) -def isolated_figures(): - with matplotlib.rc_context(): - yield - plt.close('all') - - -@pytest.fixture(params=['function', 'method']) -def draw(request): - if request.param == 'function': - return planarity.draw - return lambda edges, **kwargs: planarity.PGraph(edges).draw(**kwargs) - - -@pytest.mark.parametrize('figsize', [(6.4, 4.8), (8, 6)]) -@pytest.mark.parametrize('edges', [ - [(0, 1), (1, 2), (2, 3)], - [(1, 2)], -]) -def test_degree_one_labels_fit(draw, edges, figsize): - draw(edges, figsize=figsize) - expected = {str(node) for edge in edges for node in edge} - assert {text.get_text() for text in plt.gca().texts} == expected - - -def test_long_degree_one_label_is_still_truncated(draw): - label = 'a_very_long_vertex_label_' * 20 - draw([(label, 'b'), ('b', 'c'), ('c', 'd')]) - labels = [text.get_text() for text in plt.gca().texts] - assert len(labels) == 4 - truncated = [text for text in labels if text.endswith('...')] - assert len(truncated) == 1 - assert len(truncated[0]) < len(label) - assert set(labels) - set(truncated) == {'b', 'c', 'd'} - +try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + TEST_ENV_HAS_MATPLOTLIB = True +except ImportError as matplotlib_import_error: + TEST_ENV_HAS_MATPLOTLIB = False +import planarity -# Issue #99: label_type selects original labels or numeric vertex indexes. - -LABELED_EDGES = [('a', 'c'), ('a', 'd'), ('b', 'c'), ('b', 'd'), ('c', 'd')] - - -def _label_positions(): - return {text.get_position(): text.get_text() for text in plt.gca().texts} - - -@pytest.mark.parametrize('label_type', ['default', 'label']) -def test_label_type_shows_original_labels(draw, label_type): - draw(LABELED_EDGES, label_type=label_type) - assert set(_label_positions().values()) == {'a', 'b', 'c', 'd'} - - -def test_label_type_index_shows_vertex_indexes(draw): - draw(LABELED_EDGES, label_type='index') - assert set(_label_positions().values()) == {'1', '2', '3', '4'} - - -def test_label_type_index_matches_mapping_and_text_drawing(): - # Each vertex keeps its position; only its label text changes, and the - # index shown is the one mapping() assigns and the text renderer draws. - planarity.draw(LABELED_EDGES, label_type='label') - by_label = _label_positions() - plt.close('all') - planarity.draw(LABELED_EDGES, label_type='index') - by_index = _label_positions() - assert by_label.keys() == by_index.keys() - - mapping = planarity.mapping(LABELED_EDGES) - for position, label in by_label.items(): - assert mapping[int(by_index[position])] == label - - text_drawing = planarity.draw( - LABELED_EDGES, - output_type='text/plain', - label_type='index', - ) - assert text_drawing is not None - for index in by_index.values(): - assert index in text_drawing - +@pytest.mark.skipif( + not TEST_ENV_HAS_MATPLOTLIB, + reason="matplotlib is not installed", +) +class TestPlanarityExpressDrawLabels: + @pytest.fixture(autouse=True) + def isolated_figures(self): + with matplotlib.rc_context(): + yield + plt.close('all') + + @pytest.fixture(params=['function', 'method']) + def draw(self, request): + if request.param == 'function': + return planarity.draw + return lambda edges, **kwargs: planarity.PGraph(edges).draw(**kwargs) + + @pytest.mark.parametrize('figsize', [(6.4, 4.8), (8, 6)]) + @pytest.mark.parametrize('edges', [ + [(0, 1), (1, 2), (2, 3)], + [(1, 2)], + ]) + def test_degree_one_labels_fit(self, draw, edges, figsize): + draw(edges, figsize=figsize) + expected = {str(node) for edge in edges for node in edge} + assert {text.get_text() for text in plt.gca().texts} == expected + + + def test_long_degree_one_label_is_still_truncated(self, draw): + label = 'a_very_long_vertex_label_' * 20 + draw([(label, 'b'), ('b', 'c'), ('c', 'd')]) + labels = [text.get_text() for text in plt.gca().texts] + assert len(labels) == 4 + truncated = [text for text in labels if text.endswith('...')] + assert len(truncated) == 1 + assert len(truncated[0]) < len(label) + assert set(labels) - set(truncated) == {'b', 'c', 'd'} + + @pytest.fixture + def labeled_edges(self): + # Issue #99: label_type selects original labels or numeric vertex + # indexes. Note the use of a fixture since this list is mutable. + return [('a', 'c'), ('a', 'd'), ('b', 'c'), ('b', 'd'), ('c', 'd')] + + def _label_positions(self): + return {text.get_position(): text.get_text() for text in plt.gca().texts} + + @pytest.mark.parametrize('label_type', ['default', 'label']) + def test_label_type_shows_original_labels( + self, draw, label_type, labeled_edges + ): + draw(labeled_edges, label_type=label_type) + assert set(self._label_positions().values()) == {'a', 'b', 'c', 'd'} + + def test_label_type_index_shows_vertex_indexes(self, draw, labeled_edges): + draw(labeled_edges, label_type='index') + assert set(self._label_positions().values()) == {'1', '2', '3', '4'} + + def test_label_type_index_matches_mapping_and_text_drawing( + self, labeled_edges + ): + # Each vertex keeps its position; only its label text changes, and the + # index shown is the one mapping() assigns and the text renderer draws. + planarity.draw(labeled_edges, label_type='label') + by_label = self._label_positions() + plt.close('all') + planarity.draw(labeled_edges, label_type='index') + by_index = self._label_positions() + assert by_label.keys() == by_index.keys() + + mapping = planarity.mapping(labeled_edges) + for position, label in by_label.items(): + assert mapping[int(by_index[position])] == label + + text_drawing = planarity.draw( + labeled_edges, + output_type='text/plain', + label_type='index', + ) + assert text_drawing is not None + for index in by_index.values(): + assert index in text_drawing diff --git a/tests/test_express_api_networkx.py b/tests/test_express_api_networkx.py index d113205..70684c2 100644 --- a/tests/test_express_api_networkx.py +++ b/tests/test_express_api_networkx.py @@ -1,16 +1,23 @@ -import planarity +import pytest + from unittest import SkipTest -class TestPlanarityNetworkX: +try: + import networkx as nx + TEST_ENV_HAS_NETWORKX = True +except ImportError: + TEST_ENV_HAS_NETWORKX = False + +import planarity + +@pytest.mark.skipif( + not TEST_ENV_HAS_NETWORKX, + reason="networkx is not installed." +) +class TestPlanarityNetworkX: @classmethod def setup_class(self): - try: - global nx - import networkx as nx - except ImportError: - raise SkipTest('networkx not available.') - self.planar=[] self.planar.extend([nx.path_graph(5), nx.complete_graph(4)]) @@ -27,8 +34,10 @@ def test_is_planar(self): def test_is_planar_unions(self): try: from itertools import combinations,product - except ImportError: - raise SkipTest('itertools.combinations not found') + except ImportError as itertools_import_error: + raise SkipTest( + 'itertools.combinations not found' + ) from itertools_import_error for (G1,G2) in combinations(self.planar,2): G=nx.disjoint_union(G1,G2)