diff --git a/examples/QueryWidget.ipynb b/examples/QueryWidget.ipynb new file mode 100644 index 0000000..44725f9 --- /dev/null +++ b/examples/QueryWidget.ipynb @@ -0,0 +1,169 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Query Widget\n", + "\n", + "A simple widget for construting and visualizing a SPARQL query and its results.\n", + "\n", + "Reusable self-contained widgets are preferrable to monolithic widgets. Therefore, we\n", + "break down the widget components available in `ipyradiant` that are aggregated into a\n", + "unified `QueryWidget`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, we load an example graph file from the library data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipyradiant import FileManager, PathLoader\n", + "\n", + "lw = FileManager(loader=PathLoader(path=\"data\"))\n", + "lw.loader.file_picker.value = lw.loader.file_picker.options[\"starwars.ttl\"]\n", + "lw" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## QueryPreview\n", + "\n", + "The `QueryPreview` widget allows users to enter a `query` in the left panel, and see\n", + "live syntax highlighting in the right panel.\n", + "\n", + "In a future update, it may be possible to provide syntax highlighting and tips as part\n", + "of a single query entry widget." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipyradiant.query.visualize import QueryPreview\n", + "\n", + "qp = QueryPreview()\n", + "# Specify an example query for demonstration pu\n", + "qp.query = \"\"\"\\\n", + "PREFIX voc: \n", + "CONSTRUCT {\n", + " ?s ?p ?o .\n", + " voc:Character a rdfs:Class .\n", + "} WHERE {\n", + " {\n", + " SELECT DISTINCT ?s\n", + " WHERE {\n", + " ?s a voc:Character .\n", + " }\n", + " LIMIT 3\n", + " }\n", + " ?s ?p ?o .\n", + "}\n", + "\"\"\"\n", + "qp" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## QueryResultsGrid\n", + "\n", + "The `QueryResultsGrid` provides a simple way to view the results of a query as a grid.\n", + "\n", + "A future update may include the ability to apply operations on the grid (i.e.\n", + "filtering)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipyradiant.query.visualize import QueryResultsGrid\n", + "\n", + "qrg = QueryResultsGrid(namespaces=dict(lw.graph.namespaces()))\n", + "# set the query results for the demonstration\n", + "qrg.query_result = lw.graph.query(qp.query)\n", + "qrg" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## QueryWidget\n", + "\n", + "The `QueryWidget` aggregates the `QueryPreview` and `QueryResultsGrid` together with a\n", + "\"Run Query\" button to support the workflow of query specification, execution, and\n", + "analysis of results.\n", + "\n", + "> Tip: The results of the query can be collected via `QueryWidget.query_result`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipyradiant.query.app import QueryWidget\n", + "\n", + "qw = QueryWidget(graph=lw.graph)\n", + "# specify an example query for demonstration\n", + "qw.query = \"\"\"\\\n", + "CONSTRUCT {\n", + " ?s ?p ?o .\n", + " voc:Character a rdfs:Class .\n", + "} WHERE {\n", + " {\n", + " SELECT DISTINCT ?s\n", + " WHERE {\n", + " ?s a voc:Character .\n", + " }\n", + " LIMIT 3\n", + " }\n", + " ?s ?p ?o .\n", + "}\n", + "\"\"\"\n", + "# automate the execution of the query for demonstration\n", + "qw.run_button.click()\n", + "qw" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/RemoteQuery_Example.ipynb b/examples/RemoteQuery_Example.ipynb index 0f38cf0..b6ab7a5 100644 --- a/examples/RemoteQuery_Example.ipynb +++ b/examples/RemoteQuery_Example.ipynb @@ -218,10 +218,9 @@ "outputs": [], "source": [ "widget = WidgetExample()\n", - "widget.query.query_constructor.query_type = \"SELECT DISTINCT\"\n", - "widget.query.query_constructor.query_line = \"*\"\n", - "widget.query.query_constructor.query_body = \"\"\"\n", - "{\n", + "widget.query.query = \"\"\"\\\n", + "SELECT DISTINCT *\n", + "WHERE {\n", " SERVICE \n", " {\n", " SELECT ?s ?p ?o\n", @@ -229,19 +228,7 @@ " LIMIT 10\n", " }\n", "}\n", - "\"\"\"\n", - "widget.query.query_constructor.formatted_query.value = \"\"\"\n", - "SELECT DISTINCT *\n", - "WHERE { \n", - " SERVICE \n", - " {\n", - " SELECT ?s ?p ?o\n", - " WHERE {?s ?p ?o}\n", - " LIMIT 10\n", - " }\n", - " }\n", - "\"\"\"\n", - "widget.query" + "\"\"\"" ] }, { @@ -389,7 +376,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.9" + "version": "3.7.10" } }, "nbformat": 4, diff --git a/examples/Tab_App_Example.ipynb b/examples/Tab_App_Example.ipynb index ff9aea7..048c368 100644 --- a/examples/Tab_App_Example.ipynb +++ b/examples/Tab_App_Example.ipynb @@ -50,7 +50,6 @@ " graph = T.Instance(Graph, allow_none=True)\n", " file_manager = T.Instance(FileManager)\n", " query = T.Instance(QueryWidget)\n", - " vis = T.Instance(CytoscapeVisualizer)\n", " log = W.Output()\n", "\n", " def __init__(self, graph: Graph = None, *args, **kwargs):\n", @@ -64,12 +63,10 @@ " super().__init__(*args, **kwargs)\n", " T.link((self.file_manager, \"graph\"), (self, \"graph\"))\n", " T.link((self, \"graph\"), (self.query, \"graph\"))\n", - " T.link((self, \"graph\"), (self.vis, \"graph\"))\n", "\n", - " self.children = [self.file_manager, self.query, self.vis]\n", + " self.children = [self.file_manager, self.query]\n", " self.set_title(0, \"Load\")\n", " self.set_title(1, \"Query\")\n", - " self.set_title(2, \"Visualize\")\n", "\n", " @T.default(\"graph\")\n", " def make_default_graph(self):\n", @@ -81,11 +78,10 @@ "\n", " @T.default(\"query\")\n", " def make_default_query_widget(self):\n", - " return QueryWidget()\n", - "\n", - " @T.default(\"vis\")\n", - " def make_vis_widget(self):\n", - " return CytoscapeVisualizer()" + " qw = QueryWidget()\n", + " # set default query\n", + " qw.query = \"\"\"SELECT DISTINCT ?s ?p ?o\\nWHERE {\\n ?s ?p ?o .\\n}\\nLIMIT 10\"\"\"\n", + " return qw" ] }, { @@ -102,7 +98,7 @@ "outputs": [], "source": [ "tabs = RadiantTabs()\n", - "W.VBox([tabs, tabs.query.log])" + "tabs" ] } ], @@ -122,7 +118,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.8" + "version": "3.7.10" } }, "nbformat": 4, diff --git a/examples/Test_Tab_App.ipynb b/examples/Test_Tab_App.ipynb index f8ac65d..56c758c 100644 --- a/examples/Test_Tab_App.ipynb +++ b/examples/Test_Tab_App.ipynb @@ -39,7 +39,6 @@ "outputs": [], "source": [ "pl = tabs.file_manager.loader\n", - "vis = tabs.vis\n", "q = tabs.query" ] }, @@ -98,6 +97,21 @@ " print(f\"[{key}]\", f\"+{int(delta)}\", msg)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "TEST_QUERY = \"\"\"\\\n", + "SELECT DISTINCT ?s ?p ?o\n", + "WHERE {\n", + " ?s ?p ?o .\n", + "}\n", + "LIMIT 5\n", + "\"\"\"" + ] + }, { "cell_type": "code", "execution_count": null, @@ -111,8 +125,6 @@ " timestamp(p, \"starting...\")\n", " tabs.graph = Graph()\n", " timestamp(p, \"cleaned...\")\n", - " assert not vis.cyto_widget.graph.edges\n", - " assert not vis.cyto_widget.graph.nodes\n", " tabs.selected_index = 0\n", " timestamp(p, f\"loading...\")\n", " pl.file_picker.value = pl.file_picker.options[p]\n", @@ -120,10 +132,9 @@ " assert len(pl.graph)\n", " tabs.selected_index = 1\n", " timestamp(p, \"querying...\")\n", + " q.query = TEST_QUERY\n", " q.run_button.click()\n", - " tabs.selected_index = 2\n", - " assert vis.cyto_widget.graph.edges\n", - " assert vis.cyto_widget.graph.nodes\n", + " assert len(q.query_result) > 0, \"Failed to execute query.\"\n", " timestamp(p, \"OK!\")\n", " except Exception as err:\n", " timestamp(p, \"ERROR\")\n", @@ -185,7 +196,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.8" + "version": "3.7.10" } }, "nbformat": 4, diff --git a/src/ipyradiant/loader/manager.py b/src/ipyradiant/loader/manager.py index 70041c4..226d848 100644 --- a/src/ipyradiant/loader/manager.py +++ b/src/ipyradiant/loader/manager.py @@ -9,66 +9,43 @@ from .base import BaseLoader from .upload import UpLoader -from .util import get_n_predicates, get_n_subjects -class FileManager(W.VBox): - """Wraps a file selector and stats""" +class FileManager(W.HBox): + """Wraps a file selector with graph info.""" - n_triples = T.Int() - n_subjects = T.Int() - n_predicates = T.Int() - - loader = T.Instance(BaseLoader) - stats = T.Instance(W.HTML) - - graph = T.Instance(Graph) + loader = T.Instance(BaseLoader, default_value=UpLoader()) + graph = T.Instance(Graph, kw={}) graph_id = T.Instance(BNode) + msg = T.Instance(W.HTML) + + def build_html(self): + """Basic HTML string with graph length.""" + if len(self.graph) == 0: + return "No graph loaded." + else: + return f"Loaded graph with {len(self.loader.graph)} triples." + + @T.validate("children") + def validate_children(self, proposal): + """ + Validate method for default children. + This is necessary because @trt.default does not work on children. + """ + children = proposal.value + if not children: + children = (self.loader, self.msg) + return children + + @T.default("msg") + def make_default_msg(self): + return W.HTML(self.build_html()) - log = W.Output() + @T.observe("graph_id") + def update_msg(self, change): + self.msg.value = self.build_html() - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + @T.observe("loader") + def update_loader(self, change): T.link((self.loader, "graph"), (self, "graph")) T.link((self.loader, "graph_id"), (self, "graph_id")) - self.children = [self.loader, self.stats] - - @T.default("loader") - def make_default_loader(self): - return UpLoader() - - @T.default("n_triples") - def make_default_n_triples(self): - return len(self.graph), 0 - - @T.default("n_subjects") - def make_default_n_subjects(self): - return get_n_subjects(self.graph) - - @T.default("n_predicates") - def make_default_n_predicates(self): - return get_n_subjects(self.graph) - - def build_html_str(self): - return f""" - Stats: -
    -
  • n_triples: {self.n_triples}
  • -
      -
    • n_subjects: {self.n_subjects}
    • -
    • n_predicates: {self.n_predicates}
    • -
    -
- """ - - @T.default("stats") - def make_default_stats(self): - html = W.HTML(self.build_html_str()) - return html - - @T.observe("graph_id") - def update_stats(self, change): - self.n_triples = len(self.graph) - self.n_subjects = get_n_subjects(self.graph) - self.n_predicates = get_n_predicates(self.graph) - self.stats.value = self.build_html_str() diff --git a/src/ipyradiant/query/__init__.py b/src/ipyradiant/query/__init__.py index 91f4659..7eb7a5f 100644 --- a/src/ipyradiant/query/__init__.py +++ b/src/ipyradiant/query/__init__.py @@ -3,7 +3,8 @@ # Copyright (c) 2021 ipyradiant contributors. # Distributed under the terms of the Modified BSD License. -__all__ = ["QueryWidget", "service_patch_rdflib"] +__all__ = ["LegacyQueryWidget", "QueryWidget", "service_patch_rdflib"] -from .query_widget import QueryWidget +from .app import QueryWidget +from .query_widget import QueryWidget as LegacyQueryWidget from .utils import service_patch_rdflib diff --git a/src/ipyradiant/query/api.py b/src/ipyradiant/query/api.py index 839fbba..54efbf2 100644 --- a/src/ipyradiant/query/api.py +++ b/src/ipyradiant/query/api.py @@ -1,5 +1,6 @@ # Copyright (c) 2021 ipyradiant contributors. # Distributed under the terms of the Modified BSD License. + import logging import re diff --git a/src/ipyradiant/query/app.py b/src/ipyradiant/query/app.py new file mode 100644 index 0000000..6b804f5 --- /dev/null +++ b/src/ipyradiant/query/app.py @@ -0,0 +1,55 @@ +# Copyright (c) 2021 ipyradiant contributors. +# Distributed under the terms of the Modified BSD License. + +import ipywidgets as W +import traitlets as T +from rdflib import Graph + +from ipyradiant.query.visualize import QueryPreview, QueryResultsGrid + + +class QueryWidget(W.VBox): + """Widget used to visualize and run SPARQL queries. Results are displayed as a DataFrame grid.""" + + query = T.Instance(str, ("",)) + query_preview = T.Instance(QueryPreview) + query_result = T.Any() + query_results_grid = T.Instance(QueryResultsGrid) + graph = T.Instance(Graph, kw={}) + run_button = T.Instance(W.Button) + + @T.validate("children") + def validate_children(self, proposal): + """ + Validate method for default children. + This is necessary because @trt.default does not work on children. + """ + children = proposal.value + if not children: + children = (self.query_preview, self.run_button, self.query_results_grid) + return children + + def run_query(self, button): + self.query_result = self.graph.query(self.query) + + @T.default("query_results_grid") + def make_default_query_results_grid(self): + widget = QueryResultsGrid(namespaces=dict(self.graph.namespaces())) + T.link((widget, "query_result"), (self, "query_result")) + return widget + + @T.default("query_preview") + def make_default_query_preview(self): + widget = QueryPreview() + T.link((widget, "query"), (self, "query")) + return widget + + @T.default("run_button") + def make_default_run_button(self): + button = W.Button( + description="Run Query", + icon="search", + tooltip="Click to execute query with current configuration.", + ) + button.on_click(self.run_query) + return button diff --git a/src/ipyradiant/query/namespace_manager.py b/src/ipyradiant/query/namespace_manager.py index 2d73570..f887b46 100644 --- a/src/ipyradiant/query/namespace_manager.py +++ b/src/ipyradiant/query/namespace_manager.py @@ -17,9 +17,20 @@ def collapse_namespace(namespaces, cell): - """TODO""" + """ + TODO prevent from collapsing a partial namespace + e.g. + PREFIX ex: + URI = https://example.org/thing/stuff + + current behavior: ex:thing/stuff + expected behavior: no collapsing + """ uf_link = """{}""" + if isinstance(namespaces, dict): + namespaces = tuple(namespaces.items()) + or_statement = "|".join([uri for _, uri in namespaces]) pattern = f"({or_statement}).*" quick_check = re.match(pattern, str(cell)) diff --git a/src/ipyradiant/query/query_constructor.py b/src/ipyradiant/query/query_constructor.py index 949bd89..d4e28f9 100644 --- a/src/ipyradiant/query/query_constructor.py +++ b/src/ipyradiant/query/query_constructor.py @@ -5,12 +5,9 @@ import ipywidgets as W import traitlets as T -from pygments import highlight -from pygments.formatters import HtmlFormatter -from pygments.lexers.rdf import SparqlLexer -from pygments.styles import STYLE_MAP from .query_form import QueryInput +from .visualize import QueryColorizer # TODO improve query_template = """{} @@ -19,50 +16,6 @@ """ -class QueryColorizer(W.VBox): - """Takes sparql query and runs it through pygments lexer and html formatter""" - - query = T.Unicode() - formatter_style = T.Enum(values=list(STYLE_MAP.keys()), default_value="colorful") - style_picker = T.Instance( - W.Dropdown, - kw=dict( - description="Style", - options=list(STYLE_MAP.keys()), - layout=W.Layout(min_height="30px"), - ), - ) - html_output = T.Instance(W.HTML, kw={}) - - _style_defs = T.Unicode(default_value="") - formatter: HtmlFormatter = None - _sqrl_lexer: SparqlLexer = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - T.link((self, "formatter_style"), (self.style_picker, "value")) - self.children = [self.style_picker, self.html_output] - - @T.observe("formatter_style") - def _update_style(self, change=None) -> HtmlFormatter: - """update the css style from the formatter""" - self.formatter = HtmlFormatter(style=self.formatter_style) - self._sqrl_lexer = SparqlLexer() - self._style_defs = f"" - - @T.observe( - "query", - "_style_defs", - ) - def update_formatted_query(self, change): - """Update the html output widget with the highlighted query""" - if not self.formatter or not self._sqrl_lexer: - self._update_style() - self.html_output.value = self._style_defs + highlight( - self.query, self._sqrl_lexer, self.formatter - ) - - class QueryConstructor(W.HBox): """TODO - way better templating and more efficient formatting diff --git a/src/ipyradiant/query/visualize.py b/src/ipyradiant/query/visualize.py new file mode 100644 index 0000000..05c3d1d --- /dev/null +++ b/src/ipyradiant/query/visualize.py @@ -0,0 +1,175 @@ +# Copyright (c) 2021 ipyradiant contributors. +# Distributed under the terms of the Modified BSD License. + +import IPython +import ipywidgets as W +import traitlets as T +from pandas import DataFrame +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers.rdf import SparqlLexer +from pygments.styles import STYLE_MAP +from rdflib import URIRef +from rdflib.plugins.sparql.processor import SPARQLResult + +from ipyradiant.query.namespace_manager import collapse_namespace + + +class QueryColorizer(W.VBox): + """Takes sparql query and runs it through pygments lexer and html formatter""" + + query = T.Unicode() + formatter_style = T.Enum(values=list(STYLE_MAP.keys()), default_value="colorful") + style_picker = T.Instance(W.Dropdown) + html_output = T.Instance(W.HTML, kw={}) + + _style_defs = T.Unicode(default_value="") + formatter: HtmlFormatter = None + _sqrl_lexer: SparqlLexer = None + + @T.default("style_picker") + def make_default_style_picker(self) -> W.Dropdown: + widget = W.Dropdown( + description="Style", + options=list(STYLE_MAP.keys()), + layout=W.Layout(min_height="30px"), + ) + T.link((self, "formatter_style"), (widget, "value")) + return widget + + @T.validate("children") + def validate_children(self, proposal): + """ + Validate method for default children. + This is necessary because @trt.default does not work on children. + """ + children = proposal.value + if not children: + children = (self.style_picker, self.html_output) + return children + + @T.observe("formatter_style") + def _update_style(self, change=None) -> HtmlFormatter: + """update the css style from the formatter""" + self.formatter = HtmlFormatter(style=self.formatter_style) + self._sqrl_lexer = SparqlLexer() + self._style_defs = f"" + + @T.observe( + "query", + "_style_defs", + ) + def update_formatted_query(self, change): + """Update the html output widget with the highlighted query""" + if not self.formatter or not self._sqrl_lexer: + self._update_style() + self.html_output.value = self._style_defs + highlight( + self.query, self._sqrl_lexer, self.formatter + ) + + +class QueryPreview(W.HBox): + """A widget for writing and previewing (with syntax highlighting) a SPARQL query.""" + + query = T.Instance(str, ("",)) + query_input = T.Instance(W.Textarea) + query_view = T.Instance(QueryColorizer) + styler = T.Bool(default_value=False) + + @T.validate("children") + def validate_children(self, proposal): + """ + Validate method for default children. + This is necessary because @trt.default does not work on children. + """ + children = proposal.value + if not children: + if self.styler: + children = (self.query_input, self.query_view) + else: + # if self.styler is False, don't include in the children + children = (self.query_input, self.query_view.children[1]) + return children + + @T.default("query_input") + def make_default_query_input(self) -> W.Textarea: + widget = W.Textarea() + widget.layout = { + "display": "flex", + "flex_flow": "row", + "align_items": "stretch", + "width": "auto", + "min_width": "25%", + "max_width": "50%", + } + T.link((widget, "value"), (self, "query")) + return widget + + @T.default("query_view") + def make_default_query_view(self) -> QueryColorizer: + widget = QueryColorizer() + T.link((widget, "query"), (self, "query")) + return widget + + +class QueryResultsGrid(W.Box): + """A widget for viewing the result of SPARQL queries as a DataFrame grid.""" + + grid = T.Instance(W.Output) + log = W.Output(layout={"border": "1px solid black"}) + current_dataframe = T.Instance(DataFrame) + namespaces = T.Instance(dict, kw={}) + query_result = T.Any() + + @T.validate("children") + def validate_children(self, proposal): + """ + Validate method for default children. + This is necessary because @trt.default does not work on children. + """ + children = proposal.value + if not children: + children = (self.grid,) + return children + + @T.validate("query_result") + def validate_query_result(self, proposal): + query_result = proposal.value + if query_result: + if isinstance(query_result, DataFrame): + pass + elif isinstance(query_result, (list, tuple)): + item_len = len(query_result[0]) + assert ( + item_len == 3 + ), f"Unexpected number of items in query_result, {item_len}!=3" + query_result = DataFrame(query_result) + elif isinstance(query_result, SPARQLResult): + query_result = DataFrame(query_result) + else: + query_result = DataFrame() + + self.observe(self.run_query, "query_result") + return query_result + + @log.capture(clear_output=True) + def run_query(self, change): + # TODO move to validate method? + self.current_dataframe = DataFrame(self.query_result) + # TODO set columns + collapsed_data = DataFrame(self.query_result) + for ii, row in collapsed_data.iterrows(): + for jj, cell in enumerate(row): + if isinstance(cell, URIRef): + collapsed_data.iat[ii, jj] = collapse_namespace( + self.namespaces, cell + ) + self.grid.clear_output() + with self.grid: + IPython.display.display( + IPython.display.HTML(collapsed_data.to_html(escape=False)) + ) + + @T.default("grid") + def make_default_grid(self): + return W.Output(layout=dict(max_height="60vh")) diff --git a/src/ipyradiant/visualization/improved_cytoscape.py b/src/ipyradiant/visualization/improved_cytoscape.py index d5df452..b93424c 100644 --- a/src/ipyradiant/visualization/improved_cytoscape.py +++ b/src/ipyradiant/visualization/improved_cytoscape.py @@ -1,5 +1,6 @@ # Copyright (c) 2021 ipyradiant contributors. # Distributed under the terms of the Modified BSD License. + import ipycytoscape as cyto import ipywidgets as W import networkx as nx