Skip to content
Open
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
1 change: 1 addition & 0 deletions dgf/src/plot/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ py_library(
srcs = ["pyvis.py"],
deps = [
"//dgf/src/data:schema",
"//dgf/src/util:options",
"//third_party/py/pyvis",
],
)
Expand Down
24 changes: 19 additions & 5 deletions dgf/src/plot/pyvis.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Plotting of graph elements using the pyvis library."""
"""Plotting utilities for graph schemas using pyvis."""

from typing import Any, Dict
from typing import Any

from dgf.src.data import schema as schema_lib
from dgf.src.util import options as options_lib
from pyvis import network as pyvis_network

Context = options_lib.Context
options = options_lib.Manager("pyvis_options")
default_options = options.default_options
option_context = options.context


def _html_label(name: str, features: list[str]) -> str:
"""Creates a node/edge title for hover information."""
Expand All @@ -31,20 +37,28 @@ def plot_schema(
schema: schema_lib.GraphSchema,
features: bool = True,
*,
pyvis_kwargs: Dict[Any, Any] = {},
pyvis_kwargs: dict[Any, Any] | None = None,
) -> pyvis_network.Network:
"""Plots the graph schema's meta-graph (i.e., its nodesets and edgesets).

Args:
schema: The `GraphSchema` object to plot.
features: If true, display the node and edges features in the title (hover).
pyvis_kwargs: Additional keyword arguments to pass to the
`pyvis.network.Network` constructor.
`pyvis.network.Network` constructor. These override any default or
contextual pyvis options.

Returns:
A `pyvis.network.Network` object representing the graph schema.
"""
net = pyvis_network.Network(directed=True, **pyvis_kwargs)
final_kwargs: dict[str, Any] = {"directed": True}
final_kwargs.update(options.to_dict())

if pyvis_kwargs is not None:
final_kwargs.update(pyvis_kwargs)

clean_kwargs = {k: v for k, v in final_kwargs.items() if v is not None}
net = pyvis_network.Network(**clean_kwargs)

# Add nodes
for node_set_name in sorted(schema.node_sets.keys()):
Expand Down
35 changes: 33 additions & 2 deletions dgf/src/plot/pyvis_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for dgf.src.plot.pyvis."""

from absl.testing import absltest
from dgf.src.plot import pyvis as pyvis_plot
from dgf.src.util import gen_test_graph


class PyvisTest(absltest.TestCase):

def setUp(self):
super().setUp()
pyvis_plot.options.reset()

def tearDown(self):
pyvis_plot.options.reset()
super().tearDown()

def test_plot_schema(self):
schema = gen_test_graph.generate_schema()
net = pyvis_plot.plot_schema(schema, features=True)
Expand All @@ -34,6 +40,31 @@ def test_plot_schema(self):
self.assertIn("n1", html)
self.assertIn("n2", html)

def test_plot_schema_with_none_option_fallback(self):
schema = gen_test_graph.generate_schema()
pyvis_plot.options.set("height", "500px")

# Setting height=None instructs plot_schema to omit the kwarg
# (falling back to pyvis default 600px).
with pyvis_plot.option_context(height=None):
net = pyvis_plot.plot_schema(schema)
self.assertEqual(net.height, "600px")

net = pyvis_plot.plot_schema(schema)
self.assertEqual(net.height, "500px") # Custom height is active again.

def test_plot_schema_with_default_options_and_override(self):
schema = gen_test_graph.generate_schema()
pyvis_plot.options.set("height", "500px")

net1 = pyvis_plot.plot_schema(schema)
self.assertEqual(net1.height, "500px") # Default options applied.

net2 = pyvis_plot.plot_schema(schema, pyvis_kwargs={"height": "750px"})
self.assertEqual(net2.height, "750px") # Call-specific kwargs override.

self.assertEqual(pyvis_plot.options.get("height"), "500px") # Unchanged.


if __name__ == "__main__":
absltest.main()
14 changes: 14 additions & 0 deletions dgf/src/util/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,17 @@ py_test(
# numpy dep,
],
)

py_library(
name = "options",
srcs = ["options.py"],
)

py_test(
name = "options_test",
srcs = ["options_test.py"],
deps = [
":options",
# absl/testing:absltest dep,
],
)
160 changes: 160 additions & 0 deletions dgf/src/util/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright 2022 Google LLC.
#
# 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
#
# https://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.

"""Scoped, hierarchical options and configuration context managers."""

from collections.abc import Iterator, Mapping, MutableMapping
import contextlib
import contextvars
from typing import Any

_SENTINEL = object()


class Context(Mapping[str, Any]):
"""Immutable context scope holding a dictionary of options and an optional parent scope."""

def __init__(
self,
options: Mapping[str, Any] | None = None,
parent: "Context | None" = None,
):
self._options: dict[str, Any] = dict(options) if options is not None else {}
self._parent = parent

def get_option(self, key: str, default: Any = None) -> Any:
"""Gets the option value from this context or its parent scopes."""
if key in self._options:
return self._options[key]
if self._parent is not None:
return self._parent.get_option(key, default)
return default

def get(self, key: str, default: Any = None) -> Any:
"""Gets the option value from this context or its parent scopes."""
return self.get_option(key, default)

def __getitem__(self, key: str) -> Any:
val = self.get_option(key, default=_SENTINEL)
if val is _SENTINEL:
raise KeyError(key)
return val

def __contains__(self, key: object) -> bool:
if key in self._options:
return True
return self._parent is not None and key in self._parent

def __iter__(self) -> Iterator[str]:
return iter(self.to_dict())

def __len__(self) -> int:
return len(self.to_dict())

def with_option(self, key: str, value: Any) -> "Context":
"""Returns a new Context with the option set (Copy-on-Write)."""
new_options = self._options.copy()
new_options[key] = value
return Context(new_options, parent=self._parent)

def with_options(self, **kwargs: Any) -> "Context":
"""Returns a new Context with the given options updated (Copy-on-Write)."""
new_options = self._options.copy()
new_options.update(kwargs)
return Context(new_options, parent=self._parent)

def without_option(self, key: str | None = None) -> "Context":
"""Returns a new Context with the option(s) reset (Copy-on-Write)."""
if key is None:
new_options = {}
else:
new_options = self._options.copy()
new_options.pop(key, None)
return Context(new_options, parent=self._parent)

def to_dict(self) -> dict[str, Any]:
"""Returns the merged options dictionary up to this context level."""
merged = self._parent.to_dict() if self._parent is not None else {}
merged.update(self._options)
return merged


class Manager(MutableMapping[str, Any]):
"""Thread-safe and coroutine-safe options manager for a subsystem."""

def __init__(
self,
name: str,
defaults: Mapping[str, Any] | None = None,
):
self._default_options = Context(defaults)
self._current_context: contextvars.ContextVar[Context] = (
contextvars.ContextVar(name, default=self._default_options)
)

@property
def default_options(self) -> Context:
"""Returns the base/default options context."""
return self._default_options

def get(self, key: str, default: Any = None) -> Any:
"""Gets the active option value from the current context stack."""
return self._current_context.get().get_option(key, default)

def set(self, key: str, value: Any) -> None:
"""Sets an option in the active context scope using Copy-on-Write."""
current_context = self._current_context.get()
self._current_context.set(current_context.with_option(key, value))

def reset(self, key: str | None = None) -> None:
"""Resets an option or all options in the active context scope using Copy-on-Write."""
self._current_context.set(self._current_context.get().without_option(key))

def __getitem__(self, key: str) -> Any:
return self._current_context.get()[key]

def __setitem__(self, key: str, value: Any) -> None:
self.set(key, value)

def __delitem__(self, key: str) -> None:
if key not in self:
raise KeyError(key)
self.reset(key)

def __contains__(self, key: object) -> bool:
return key in self._current_context.get()

def __iter__(self) -> Iterator[str]:
return iter(self._current_context.get())

def __len__(self) -> int:
return len(self._current_context.get())

def to_dict(self) -> dict[str, Any]:
"""Returns the merged dictionary of active options."""
return self._current_context.get().to_dict()

@contextlib.contextmanager
def context(self, **kwargs: Any) -> Iterator[Context]:
"""Context manager to temporarily override options in a stack-safe scope."""
ctx = Context(kwargs.copy(), parent=self._current_context.get())
token = self._current_context.set(ctx)
try:
yield ctx
finally:
self._current_context.reset(token)

def __call__(self, **kwargs: Any) -> Any:
"""Allows manager instance to be used directly as a context manager: `with options(...)`."""
return self.context(**kwargs)
Loading