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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- **`PlotterBase`'s swallow-and-continue paths are exercised rather than assumed**: the five `maybe_*` optional-import helpers (cudf, dask_cudf, dask.dataframe, pyspark, polars) now have tests pinning that each returns `None` when its library is absent and the module when present, which is the contract engine dispatch is built on; and two `except:` guards that must never be able to fail an upload — a failure inside `infer_labels`, and an edges table whose `len()` raises — are pinned with tests that fail if the guard is removed. No behaviour change: a bare `1` used as a no-op statement becomes `pass`, and two no-ops that merely followed a `logger.debug(...)` are deleted. Retires 9 findings from the pyright ratchet.

- **`graphistry.outliers` is importable again without matplotlib**, which is not a dependency, so this was the default install. The module guards its optional imports with `try`/`except` and binds each name to `None` on failure, but two things defeated that: `import matplotlib.font_manager` binds `matplotlib`, and that name was missing from the fallback list (`outliers.py:162` would have raised `NameError`); and `get_outliers`' signature annotates `Union[np.ndarray, pd.DataFrame]`, evaluated at `def` time, so with `np = None` the module raised `AttributeError: 'NoneType' object has no attribute 'ndarray'` before reaching any function. Annotations are now lazy (`from __future__ import annotations`) and the fallback list is complete, so the module imports and degrades as it was written to. Nothing inside `graphistry/` imports it, so this only affected callers of `graphistry.outliers` directly.
- **An optional-import guard no longer leaves a name unbound on the path that skips it**: `if polars: import polars as pl` in `compute/gfql_unified.py` makes `pl` a function-local, so the pandas path left it unbound rather than falling back to any module-level binding. Both arms are guarded by the same flag, so no behaviour changes; the binding is made explicit instead.

Expand Down
1 change: 0 additions & 1 deletion bin/ci/ci_pyright_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"graphistry/compute/gfql/temporal_text.py": 1
},
"reportUnusedExpression": {
"graphistry/PlotterBase.py": 9,
"graphistry/gremlin.py": 1,
"graphistry/layouts.py": 1,
"graphistry/plugins/cugraph.py": 3,
Expand Down
1 change: 1 addition & 0 deletions bin/test-polars.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ python -m pytest --version
# `graphistry/tests/compute/gfql/test_polars_lane_completeness.py` parses this array and
# fails if any module-level polars-gated test file is absent from it (or listed but gone).
POLARS_TEST_FILES=(
graphistry/tests/test_plotterbase_optional_deps.py
graphistry/tests/compute/test_polars.py
# cache-coverage lock: its static scans run everywhere, but the functional pin for the
# polars single-alias lowering memo can only execute where polars is installed
Expand Down
16 changes: 7 additions & 9 deletions graphistry/PlotterBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def maybe_cudf():
import cudf
return cudf
except ImportError:
1
pass
except RuntimeError:
logger.warning('Runtime error import cudf: Available but failed to initialize', exc_info=True)
return None
Expand All @@ -164,7 +164,7 @@ def maybe_dask_cudf():
import dask_cudf
return dask_cudf
except ImportError:
1
pass
except RuntimeError:
logger.warning('Runtime error import dask_cudf: Available but failed to initialize', exc_info=True)
return None
Expand All @@ -175,7 +175,7 @@ def maybe_dask_dataframe():
import dask.dataframe as dd
return dd
except ImportError:
1
pass
except RuntimeError:
logger.warning('Runtime error import dask.dataframe: Available but failed to initialize', exc_info=True)
return None
Expand All @@ -186,7 +186,7 @@ def maybe_spark():
import pyspark
return pyspark
except ImportError:
1
pass
except RuntimeError:
logger.warning('Runtime error import pyspark: Available but failed to initialize', exc_info=True)
return None
Expand All @@ -197,7 +197,7 @@ def maybe_polars():
import polars
return polars
except ImportError:
1
pass
except RuntimeError:
logger.warning('Runtime error importing polars', exc_info=True)
return None
Expand Down Expand Up @@ -3018,7 +3018,7 @@ def _plot_dispatch(
try:
g = cast(PlotterBase, self.infer_labels())
except:
1
pass

def make_arrow_upload(edges: Any, upload_nodes: Any) -> ArrowUploader:
edges_arr = g._table_to_arrow(
Expand Down Expand Up @@ -3412,7 +3412,6 @@ def _table_to_arrow(
logger.debug('pd->arrow memoization miss for id (of %s): %s', len(PlotterBase._pd_hash_to_arrow), hashed)
except:
logger.debug('Failed to hash pdf', exc_info=True)
1

try:
out = pa.Table.from_pandas(table, preserve_index=False).replace_schema_metadata({})
Expand Down Expand Up @@ -3455,7 +3454,6 @@ def _table_to_arrow(
logger.debug('cudf->arrow memoization miss for id (of %s): %s', len(PlotterBase._cudf_hash_to_arrow), hashed)
except:
logger.debug('Failed to hash cudf', exc_info=True)
1

try:
out = table.to_arrow()
Expand Down Expand Up @@ -3658,7 +3656,7 @@ def _make_arrow_dataset(self, edges: Optional[pa.Table], nodes: Optional[pa.Tabl
if edges is not None and len(edges) == 0:
warn('Graph has no edges, may have rendering issues')
except:
1
pass

au : ArrowUploader = ArrowUploader(
client_session=self.session,
Expand Down
123 changes: 123 additions & 0 deletions graphistry/tests/test_plotterbase_optional_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Pins the optional-dependency contract of PlotterBase's maybe_* helpers.

Engine dispatch relies on each returning None when its library is absent rather than
raising. The helpers are lru_cached, so each test clears the cache around itself.
"""

import sys

import pytest

from graphistry.PlotterBase import (
maybe_cudf,
maybe_dask_cudf,
maybe_dask_dataframe,
maybe_polars,
maybe_spark,
)

HELPERS = [
(maybe_cudf, "cudf"),
(maybe_dask_cudf, "dask_cudf"),
(maybe_dask_dataframe, "dask.dataframe"),
(maybe_spark, "pyspark"),
(maybe_polars, "polars"),
]


@pytest.fixture
def blocked(monkeypatch):
"""Make a module unimportable for the duration of one test."""
def _block(name: str) -> None:
root = name.split(".")[0]
for key in [k for k in sys.modules if k == root or k.startswith(root + ".")]:
monkeypatch.delitem(sys.modules, key, raising=False)
monkeypatch.setitem(sys.modules, root, None)
return _block


@pytest.mark.parametrize("helper,module", HELPERS, ids=[m for _, m in HELPERS])
def test_helper_returns_none_when_its_library_is_absent(helper, module, blocked):
helper.cache_clear()
try:
blocked(module)
assert helper() is None
finally:
helper.cache_clear()


@pytest.mark.parametrize("helper,module", HELPERS, ids=[m for _, m in HELPERS])
def test_helper_returns_the_module_when_its_library_is_present(helper, module):
pytest.importorskip(module)
helper.cache_clear()
try:
assert helper() is not None
finally:
helper.cache_clear()


def test_the_result_is_cached(monkeypatch):
"""lru_cache is load-bearing: these are called per engine-dispatch decision."""
maybe_cudf.cache_clear()
try:
first = maybe_cudf()
assert maybe_cudf() is first
assert maybe_cudf.cache_info().hits >= 1
finally:
maybe_cudf.cache_clear()


class _LenRaises:
"""Stands in for an edges table whose length cannot be taken."""

def __len__(self) -> int:
raise RuntimeError("length unavailable")


def test_make_arrow_dataset_survives_an_edges_table_whose_length_raises():
"""The empty-graph warning is advisory; it must never be able to fail an upload."""
import graphistry
from graphistry.PlotterBase import PlotterBase

g = graphistry.bind()
assert isinstance(g, PlotterBase)
au = g._make_arrow_dataset(
edges=_LenRaises(), # type: ignore[arg-type]
nodes=None,
name="n",
description="d",
metadata=None,
)
assert au is not None


def test_plot_dispatch_continues_when_label_inference_raises(monkeypatch):
"""infer_labels is a convenience; a failure in it must not break the upload path."""
import pandas as pd

import graphistry
from graphistry.PlotterBase import PlotterBase

calls = []

def boom(self):
calls.append(1)
raise RuntimeError("inference exploded")

monkeypatch.setattr(PlotterBase, "infer_labels", boom, raising=True)

edges = pd.DataFrame({"s": ["a"], "d": ["b"]})
nodes = pd.DataFrame({"n": ["a", "b"], "lbl": ["x", "y"]})
g = graphistry.edges(edges, "s", "d").nodes(nodes, "n")

au = g._plot_dispatch(
graph=edges,
nodes=nodes,
name="n",
description="d",
metadata=None,
memoize=False,
)

assert calls, "the test did not reach infer_labels, so it proves nothing"
assert au is not None
Loading