Repository: jupyrdf/ipyradiant (language: python)

Issue:
Add unit testing for SPARQLQueryFramer
Need a more comprehensive set of unit tests for the SPARQLQueryFramer class. 

This should include a set of tests for basic metaclass designs that work with the framer. 


Discussion / hints:

Fix patch:
diff --git a/anaconda-project-lock.yml b/anaconda-project-lock.yml
index 11b75e7..1597259 100644
--- a/anaconda-project-lock.yml
+++ b/anaconda-project-lock.yml
@@ -39,8 +39,8 @@ env_specs:
         - cloudpickle=1.6.0=py_0
         - colorcet=2.0.6=pyhd8ed1ab_0
         - cycler=0.10.0=py_2
-        - dask-core=2021.3.1=pyhd8ed1ab_0
-        - dask=2021.3.1=pyhd8ed1ab_0
+        - dask-core=2021.4.0=pyhd8ed1ab_0
+        - dask=2021.4.0=pyhd8ed1ab_0
         - dataclasses=0.8=pyhc8e2a94_1
         - datashader=0.12.1=pyh44b312d_0
         - datashape=0.5.4=py_1
@@ -48,7 +48,7 @@ env_specs:
         - defusedxml=0.7.1=pyhd8ed1ab_0
         - entrypoints=0.3=pyhd8ed1ab_1003
         - frozendict=1.2=pyh9f0ad1d_3
-        - fsspec=0.8.7=pyhd8ed1ab_0
+        - fsspec=0.9.0=pyhd8ed1ab_0
         - heapdict=1.0.1=py_0
         - holoviews=1.14.2=pyhd8ed1ab_0
         - html5lib=1.1=pyh9f0ad1d_0
@@ -77,9 +77,9 @@ env_specs:
         - multipledispatch=0.6.0=py_0
         - nbclassic=0.2.6=pyhd8ed1ab_0
         - nbclient=0.5.3=pyhd8ed1ab_0
-        - nbformat=5.1.2=pyhd8ed1ab_1
+        - nbformat=5.1.3=pyhd8ed1ab_0
         - nest-asyncio=1.5.1=pyhd8ed1ab_0
-        - networkx=2.5=py_0
+        - networkx=2.5.1=pyhd8ed1ab_0
         - olefile=0.46=pyh9f0ad1d_1
         - owlrl=5.2.1=py_0
         - packaging=20.9=pyh44b312d_0
@@ -91,7 +91,7 @@ env_specs:
         - pickleshare=0.7.5=py_1003
         - pip=21.0.1=pyhd8ed1ab_0
         - pooch=1.3.0=pyhd8ed1ab_0
-        - prometheus_client=0.9.0=pyhd3deb0d_0
+        - prometheus_client=0.10.0=pyhd8ed1ab_0
         - prompt-toolkit=3.0.18=pyha770c72_0
         - py=1.10.0=pyhd3deb0d_0
         - pycparser=2.20=pyh9f0ad1d_2
@@ -115,10 +115,10 @@ env_specs:
         - spectate=1.0.1=pyhd8ed1ab_0
         - tblib=1.7.0=pyhd8ed1ab_0
         - testpath=0.4.4=py_0
-        - tifffile=2021.3.17=pyhd8ed1ab_0
+        - tifffile=2021.3.31=pyhd8ed1ab_0
         - toml=0.10.2=pyhd8ed1ab_0
         - toolz=0.11.1=py_0
-        - tqdm=4.59.0=pyhd8ed1ab_0
+        - tqdm=4.60.0=pyhd8ed1ab_0
         - traitlets=5.0.5=py_0
         - traittypes=0.2.1=pyh9f0ad1d_2
         - typing_extensions=3.7.4.3=py_0
@@ -154,11 +154,11 @@ env_specs:
         - coverage=5.5=py37h5e8e339_0
         - cryptography=3.4.7=py37h5d9358c_0
         - cytoolz=0.11.0=py37h5e8e339_3
-        - distributed=2021.3.1=py37h89c1867_0
+        - distributed=2021.4.0=py37h89c1867_0
         - freetype=2.10.4=h0708190_1
         - giflib=5.2.1=h36c2ea0_2
         - icu=68.1=h58526e2_0
-        - imagecodecs=2021.1.28=py37h1985380_0
+        - imagecodecs=2021.3.31=py37haf4b6ec_0
         - importlib-metadata=3.10.0=py37h89c1867_0
         - importlib_resources=5.1.2=py37h89c1867_0
         - ipykernel=5.5.3=py37h085eea5_0
@@ -209,12 +209,12 @@ env_specs:
         - openssl=1.1.1k=h7f98852_0
         - pandas=1.2.3=py37hdc94413_0
         - pandoc=2.12=h7f98852_0
-        - pillow=8.1.2=py37h4600e1f_0
+        - pillow=8.1.2=py37h4600e1f_1
         - pluggy=0.13.1=py37h89c1867_4
         - psutil=5.8.0=py37h5e8e339_1
         - pyrsistent=0.17.3=py37h5e8e339_2
         - pysocks=1.7.1=py37h89c1867_3
-        - pytest=6.2.2=py37h89c1867_0
+        - pytest=6.2.3=py37h89c1867_0
         - python=3.7.10=hffdb5ce_100_cpython
         - pywavelets=1.1.1=py37h902c9e0_3
         - pyyaml=5.4.1=py37h5e8e339_0
@@ -228,7 +228,7 @@ env_specs:
         - snappy=1.1.8=he1b5a44_3
         - sniffio=1.2.0=py37h89c1867_1
         - sparqlwrapper=1.8.5=py37h89c1867_1005
-        - sqlite=3.35.3=h74cdb3f_0
+        - sqlite=3.35.4=h74cdb3f_0
         - terminado=0.9.4=py37h89c1867_0
         - tk=8.6.10=h21135ba_1
         - tornado=6.1=py37h5e8e339_1
@@ -257,11 +257,11 @@ env_specs:
         - coverage=5.5=py37h271585c_0
         - cryptography=3.4.7=py37hce4a858_0
         - cytoolz=0.11.0=py37hf967b71_3
-        - distributed=2021.3.1=py37hf985489_0
+        - distributed=2021.4.0=py37hf985489_0
         - freetype=2.10.4=h4cff582_1
         - giflib=5.2.1=hbcb3906_2
         - icu=68.1=h74dc148_0
-        - imagecodecs=2021.1.28=py37h9f08f5f_0
+        - imagecodecs=2021.3.31=py37h8c75038_0
         - importlib-metadata=3.10.0=py37hf985489_0
         - importlib_resources=5.1.2=py37hf985489_0
         - ipykernel=5.5.3=py37h85f7c60_0
@@ -292,7 +292,7 @@ env_specs:
         - libxml2=2.9.10=h802ca4f_3
         - libxslt=1.1.33=h5739fc3_2
         - libzopfli=1.0.3=h046ec9c_0
-        - llvm-openmp=11.1.0=hda6cdc1_0
+        - llvm-openmp=11.1.0=hda6cdc1_1
         - llvmlite=0.36.0=py37h62ea057_0
         - lxml=4.6.3=py37h250624f_0
         - lz4-c=1.9.3=h046ec9c_0
@@ -315,7 +315,7 @@ env_specs:
         - psutil=5.8.0=py37hf967b71_1
         - pyrsistent=0.17.3=py37hf967b71_2
         - pysocks=1.7.1=py37hf985489_3
-        - pytest=6.2.2=py37hf985489_0
+        - pytest=6.2.3=py37hf985489_0
         - python=3.7.10=h7728216_100_cpython
         - pywavelets=1.1.1=py37h37391d0_3
         - pyyaml=5.4.1=py37hf967b71_0
@@ -329,7 +329,7 @@ env_specs:
         - snappy=1.1.8=hb1e8313_3
         - sniffio=1.2.0=py37hf985489_1
         - sparqlwrapper=1.8.5=py37hf985489_1005
-        - sqlite=3.35.3=h44b9ce1_0
+        - sqlite=3.35.4=h44b9ce1_0
         - terminado=0.9.4=py37hf985489_0
         - tk=8.6.10=h0419947_1
         - tornado=6.1=py37hf967b71_1
@@ -358,13 +358,13 @@ env_specs:
         - coverage=5.5=py37hcc03f2d_0
         - cryptography=3.4.7=py37h20c650d_0
         - cytoolz=0.11.0=py37hcc03f2d_3
-        - distributed=2021.3.1=py37h03978a9_0
+        - distributed=2021.4.0=py37h03978a9_0
         - freetype=2.10.4=h546665d_1
         - giflib=5.2.1=h8d14728_2
-        - imagecodecs=2021.1.28=py37hb664a41_0
+        - imagecodecs=2021.3.31=py37hc6c0c29_0
         - importlib-metadata=3.10.0=py37h03978a9_0
         - importlib_resources=5.1.2=py37h03978a9_0
-        - intel-openmp=2020.3=h57928b3_311
+        - intel-openmp=2021.2.0=h57928b3_616
         - ipykernel=5.5.3=py37h7813e69_0
         - ipython=7.22.0=py37h7813e69_0
         - jedi=0.18.0=py37h03978a9_2
@@ -412,12 +412,12 @@ env_specs:
         - openssl=1.1.1k=h8ffe710_0
         - pandas=1.2.3=py37h08fd248_0
         - pandoc=2.13=h8ffe710_0
-        - pillow=8.1.2=py37h96663a1_0
+        - pillow=8.1.2=py37h96663a1_1
         - pluggy=0.13.1=py37h03978a9_4
         - psutil=5.8.0=py37hcc03f2d_1
         - pyrsistent=0.17.3=py37hcc03f2d_2
         - pysocks=1.7.1=py37h03978a9_3
-        - pytest=6.2.2=py37h03978a9_0
+        - pytest=6.2.3=py37h03978a9_0
         - python=3.7.10=h7840368_100_cpython
         - pywavelets=1.1.1=py37hda49f71_3
         - pywin32=300=py37hcc03f2d_0
@@ -432,7 +432,7 @@ env_specs:
         - snappy=1.1.8=ha925a31_3
         - sniffio=1.2.0=py37h03978a9_1
         - sparqlwrapper=1.8.5=py37h03978a9_1005
-        - sqlite=3.35.3=h8ffe710_0
+        - sqlite=3.35.4=h8ffe710_0
         - terminado=0.9.4=py37h03978a9_0
         - tk=8.6.10=h8ffe710_1
         - tornado=6.1=py37hcc03f2d_1
@@ -694,7 +694,7 @@ env_specs:
         - requests=2.25.1=pyhd3deb0d_0
         - rfc3986=1.4.0=pyh9f0ad1d_0
         - six=1.15.0=pyh9f0ad1d_0
-        - tqdm=4.59.0=pyhd8ed1ab_0
+        - tqdm=4.60.0=pyhd8ed1ab_0
         - twine=3.4.1=pyhd8ed1ab_0
         - typing_extensions=3.7.4.3=py_0
         - urllib3=1.26.4=pyhd8ed1ab_0
@@ -713,7 +713,7 @@ env_specs:
         - conda-package-handling=1.7.2=py37hb5d75c8_0
         - cryptography=3.4.7=py37h5d9358c_0
         - dbus=1.13.6=h48d8840_2
-        - docutils=0.16=py37h89c1867_3
+        - docutils=0.17=py37h89c1867_0
         - expat=2.3.0=h9c3ff4c_0
         - gettext=0.19.8.1=h0b5b191_1005
         - glib-tools=2.68.0=h9c3ff4c_2
@@ -738,7 +738,7 @@ env_specs:
         - ruamel_yaml=0.15.80=py37h5e8e339_1004
         - secretstorage=3.3.1=py37h89c1867_0
         - setuptools=49.6.0=py37h89c1867_3
-        - sqlite=3.35.3=h74cdb3f_0
+        - sqlite=3.35.4=h74cdb3f_0
         - tk=8.6.10=h21135ba_1
         - xz=5.2.5=h516909a_1
         - yaml=0.2.5=h516909a_0
@@ -752,7 +752,7 @@ env_specs:
         - cmarkgfm=0.5.3=py37h271585c_0
         - conda-package-handling=1.7.2=py37h395d20d_0
         - cryptography=3.4.7=py37hce4a858_0
-        - docutils=0.16=py37hf985489_3
+        - docutils=0.17=py37hf985489_0
         - importlib-metadata=3.10.0=py37hf985489_0
         - keyring=23.0.1=py37hf985489_0
         - libcxx=11.1.0=habf9029_0
@@ -765,7 +765,7 @@ env_specs:
         - readline=8.1=h05e3726_0
         - ruamel_yaml=0.15.80=py37h7585375_1004
         - setuptools=49.6.0=py37hf985489_3
-        - sqlite=3.35.3=h44b9ce1_0
+        - sqlite=3.35.4=h44b9ce1_0
         - tk=8.6.10=h0419947_1
         - xz=5.2.5=haf1e3a3_1
         - yaml=0.2.5=haf1e3a3_0
@@ -779,7 +779,7 @@ env_specs:
         - cmarkgfm=0.5.3=py37hcc03f2d_0
         - conda-package-handling=1.7.2=py37h6dbccfb_0
         - cryptography=3.4.7=py37h20c650d_0
-        - docutils=0.16=py37h03978a9_3
+        - docutils=0.17=py37h03978a9_0
         - importlib-metadata=3.10.0=py37h03978a9_0
         - keyring=23.0.1=py37h03978a9_0
         - menuinst=1.4.16=py37hc8dfbb8_1
@@ -791,7 +791,7 @@ env_specs:
         - pywin32=300=py37hcc03f2d_0
         - ruamel_yaml=0.15.80=py37hcc03f2d_1004
         - setuptools=49.6.0=py37h03978a9_3
-        - sqlite=3.35.3=h8ffe710_0
+        - sqlite=3.35.4=h8ffe710_0
         - vc=14.2=hb210afc_4
         - vs2015_runtime=14.28.29325=h5e1d092_4
         - win_inet_pton=1.1.0=py37h03978a9_2
diff --git a/anaconda-project.yml b/anaconda-project.yml
index 80c26f3..33bb89b 100644
--- a/anaconda-project.yml
+++ b/anaconda-project.yml
@@ -16,6 +16,12 @@ commands:
     windows: *labBuild
     env_spec: dev
 
+  test:pytest:
+    description: test ipyradiant package.
+    windows: cd src\ipyradiant && pytest -ra
+    unix: cd src/ipyradiant && pytest -ra
+    env_spec: dev
+
 env_specs:
   dev:
     platforms:
diff --git a/examples/SPARQL_Metaclass_Queries.ipynb b/examples/SPARQL_Metaclass_Queries.ipynb
index bb5029f..51ecd41 100644
--- a/examples/SPARQL_Metaclass_Queries.ipynb
+++ b/examples/SPARQL_Metaclass_Queries.ipynb
@@ -137,7 +137,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from ipyradiant.query.api import SPARQLQueryFramer\n",
+    "from ipyradiant.query.framer import SPARQLQueryFramer\n",
     "\n",
     "\n",
     "class CharacterLabels(SPARQLQueryFramer):\n",
@@ -310,7 +310,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from ipyradiant.query.api import build_values\n",
+    "from ipyradiant.query.framer import build_values\n",
     "\n",
     "\n",
     "class MetaSubjectOfType(type):\n",
@@ -464,7 +464,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.8"
+   "version": "3.7.10"
   }
  },
  "nbformat": 4,
diff --git a/examples/Test_InteractiveViewer.ipynb b/examples/Test_InteractiveViewer.ipynb
index e466080..b7eaf5a 100644
--- a/examples/Test_InteractiveViewer.ipynb
+++ b/examples/Test_InteractiveViewer.ipynb
@@ -26,7 +26,7 @@
     "from rdflib import Graph\n",
     "from requests_cache import CachedSession\n",
     "\n",
-    "from ipyradiant.query.api import SPARQLQueryFramer\n",
+    "from ipyradiant.query.framer import SPARQLQueryFramer\n",
     "\n",
     "with importnb.Notebook():\n",
     "    try:\n",
@@ -234,4 +234,4 @@
  },
  "nbformat": 4,
  "nbformat_minor": 4
-}
+}
\ No newline at end of file
diff --git a/src/ipyradiant/__init__.py b/src/ipyradiant/__init__.py
index 18881bc..b561af9 100644
--- a/src/ipyradiant/__init__.py
+++ b/src/ipyradiant/__init__.py
@@ -11,7 +11,7 @@
     collapse_predicates,
 )
 from .loader import FileManager, PathLoader, UpLoader
-from .query import QueryWidget, service_patch_rdflib
+from .query import QueryWidget, SPARQLQueryFramer, service_patch_rdflib
 from .visualization import (
     CytoscapeViewer,
     CytoscapeVisualizer,
@@ -35,5 +35,6 @@
     "PredicateMultiselectApp",
     "QueryWidget",
     "service_patch_rdflib",
+    "SPARQLQueryFramer",
     "UpLoader",
 ]
diff --git a/src/ipyradiant/query/__init__.py b/src/ipyradiant/query/__init__.py
index cc049fb..5919421 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__ = ["QueryWidget", "SPARQLQueryFramer", "service_patch_rdflib"]
 
 from .app import QueryWidget
+from .framer import SPARQLQueryFramer
 from .utils import service_patch_rdflib
diff --git a/src/ipyradiant/query/api.py b/src/ipyradiant/query/framer.py
similarity index 88%
rename from src/ipyradiant/query/api.py
rename to src/ipyradiant/query/framer.py
index 82e89e5..dd7ad6f 100644
--- a/src/ipyradiant/query/api.py
+++ b/src/ipyradiant/query/framer.py
@@ -80,8 +80,11 @@ class SPARQLQueryFramer:
     columns = None
     query = None
 
-    # low cost trait (previous cls.sparql state)
+    # low cost traits
+    # previous cls.sparql state
     p_sparql = ""
+    # previous cls.initNs state
+    p_initNs = None
 
     @classmethod
     def print_vars(cls) -> None:
@@ -99,33 +102,36 @@ def print_vars(cls) -> None:
     @classmethod
     def print_potential_bindings(cls) -> None:
         """Utility function to print bindings in the sparql string.
-        Note, this method is regex-based, and may not be 100% accurate.
+        Note: this method is regex-based, and may not be 100% accurate.
         """
         if not cls.sparql:
             print("No sparql string set in class.")
-            return None
 
         logging.warning("Bindings are not guaranteed to be 100% accurate")
         potential_bindings = [
             str(binding) for binding in set(BINDING_PATTERN.findall(cls.sparql))
         ]
         print("Potential bindings:\n", sorted(potential_bindings))
-        return None
 
     @classmethod
     def run_query(
         cls,
         graph: Graph,
         initBindings: dict = None,
+        initNs: dict = None,
         **initBindingsKwarg,
     ) -> DataFrame:
         """Runs a query with optional initBindings, and returns the results as a
           pandas.DataFrame.
 
+        TODO throw error when duplicate bindings/namespaces collide
+        TODO resolve query if bindings or namespaces have changed
+
         :param graph: the rdflib.graph.Graph to be queried
         :param initBindings: a dictionary of bindings where the key is the variable in
             the sparql string, and the value is the URI/Literal to BIND to the variable.
         :param initBindingsKwarg: kwarg version of initBindings
+        :param initNs: kwarg version of initNs
         :return: pandas.DataFrame containing the contents of the SPARQL query
             result from rdflib
         """
@@ -133,18 +139,26 @@ def run_query(
             cls.query or cls.sparql
         ), "No rdflib Query or SPARQL string has been set for the class."
 
-        # Check if query should be updated due to stale sparql string
-        update_query = cls.p_sparql != cls.sparql
-        if not cls.query or update_query:
-            cls.query = prepareQuery(cls.sparql, initNs=cls.initNs)
-
         # note: merge method kwargs with default class bindings
         if initBindings:
             all_bindings = {**cls.classBindings, **initBindings, **initBindingsKwarg}
         else:
             all_bindings = {**cls.classBindings, **initBindingsKwarg}
 
-        result = graph.query(cls.query, initBindings=all_bindings)
+        # note: merge method kwargs with default namespace
+        if initNs:
+            initNs = {**cls.initNs, **initNs}
+        else:
+            initNs = {**cls.initNs}
+
+        # Check if query should be updated due to stale sparql string
+        update_query = cls.p_sparql != cls.sparql
+        if not cls.query or update_query or cls.initNs != cls.p_initNs:
+            cls.query = prepareQuery(cls.sparql, initNs=initNs)
+            if cls.initNs:
+                cls.p_initNs = cls.initNs
+
+        result = graph.query(cls.query, initBindings=all_bindings, initNs=initNs)
 
         if cls.columns is None:
             # Try to infer from query vars
diff --git a/src/ipyradiant/rdf2nx/converter.py b/src/ipyradiant/rdf2nx/converter.py
index 4432f23..a812e94 100644
--- a/src/ipyradiant/rdf2nx/converter.py
+++ b/src/ipyradiant/rdf2nx/converter.py
@@ -10,7 +10,7 @@
 from rdflib import Literal, URIRef
 from rdflib.namespace import Namespace, NamespaceManager
 
-from ..query.api import SPARQLQueryFramer
+from ..query.framer import SPARQLQueryFramer
 from .edges import ReifiedRelations, RelationProperties, RelationTypes
 from .literal_converter import cast_literal
 from .nodes import NodeIRIs, NodeProperties, NodeTypes
diff --git a/src/ipyradiant/rdf2nx/edges.py b/src/ipyradiant/rdf2nx/edges.py
index bfce54c..20de171 100644
--- a/src/ipyradiant/rdf2nx/edges.py
+++ b/src/ipyradiant/rdf2nx/edges.py
@@ -1,6 +1,6 @@
 # Copyright (c) 2021 ipyradiant contributors.
 # Distributed under the terms of the Modified BSD License.
-from ..query.api import SPARQLQueryFramer
+from ..query.framer import SPARQLQueryFramer
 
 
 class RelationTypes(SPARQLQueryFramer):
diff --git a/src/ipyradiant/rdf2nx/nodes.py b/src/ipyradiant/rdf2nx/nodes.py
index 3553ec7..d003da9 100644
--- a/src/ipyradiant/rdf2nx/nodes.py
+++ b/src/ipyradiant/rdf2nx/nodes.py
@@ -3,7 +3,7 @@
 import pandas
 import rdflib
 
-from ..query.api import SPARQLQueryFramer
+from ..query.framer import SPARQLQueryFramer
 
 
 class NodeIRIs(SPARQLQueryFramer):
diff --git a/src/ipyradiant/visualization/cytoscape/interactive.py b/src/ipyradiant/visualization/cytoscape/interactive.py
index 78239ae..30627cb 100644
--- a/src/ipyradiant/visualization/cytoscape/interactive.py
+++ b/src/ipyradiant/visualization/cytoscape/interactive.py
@@ -8,7 +8,7 @@
 from rdflib.term import URIRef
 
 from ipyradiant.basic_tools.custom_uri_ref import CustomURIRef
-from ipyradiant.query.api import SPARQLQueryFramer
+from ipyradiant.query.framer import SPARQLQueryFramer
 from ipyradiant.visualization.cytoscape import style
 from ipyradiant.visualization.cytoscape.viewer import CytoscapeViewer
 


Test patch:
diff --git a/src/ipyradiant/tests/test_framer.py b/src/ipyradiant/tests/test_framer.py
new file mode 100644
index 0000000..42c6bbd
--- /dev/null
+++ b/src/ipyradiant/tests/test_framer.py
@@ -0,0 +1,235 @@
+# Copyright (c) 2021 ipyradiant contributors.
+# Distributed under the terms of the Modified BSD License.
+from ipyradiant.query.framer import SPARQLQueryFramer, build_values
+
+
+class TestFrame1(SPARQLQueryFramer):
+    sparql = """
+    SELECT DISTINCT ?s ?p ?o
+    WHERE {
+        ?s ?p ?o .
+    }
+    """
+
+
+class TestFrame2(SPARQLQueryFramer):
+    sparql = """
+    SELECT DISTINCT ?p ?o
+    WHERE {
+        ns:Protagonist ?p ?o .
+    }
+    """
+
+
+class TestFrame3(SPARQLQueryFramer):
+    sparql = """
+    SELECT DISTINCT ?s ?p ?o
+    WHERE {
+        ?s ?p ?o .
+    }
+    """
+    columns = ["subject", "predicate", "object"]
+
+
+class TestFrame4(SPARQLQueryFramer):
+    sparql = """
+    SELECT DISTINCT ?s ?p ?o
+    WHERE {
+        ?s ?p ?o .
+    }
+    LIMIT 1
+    """
+
+
+class TestMetaclass(type):
+    """Metaclass to query for specific VALUES."""
+
+    _sparql = """
+        SELECT DISTINCT ?subject ?type
+        WHERE {{
+            ?subject a ?type .
+
+            VALUES ({}) {{
+                {}
+            }}
+        }}
+        ORDER BY DESC(?type)
+    """
+    values = None
+
+    @property
+    def sparql(cls):
+        return build_values(cls._sparql, cls.values)
+
+
+class TestFrame5(SPARQLQueryFramer, metaclass=TestMetaclass):
+    values = None
+
+
+class TestFramer:
+    # TODO how to move fixture to class attribute? pytest auto_use?
+
+    def test_framer_run_query(self, simple_rdf_graph):
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        assert len(res_df)
+
+    def test_framer_namespace(self, simple_rdf_graph, example_ns):
+        """Test the framer to ensure that different configurations of the namespace
+        (class attr, kwarg, etc.) provide proper SPARQL query execution.
+        """
+
+        # test without namespace (expect to fail)
+        try:
+            _ = TestFrame2.run_query(simple_rdf_graph)
+            raise Exception(
+                "Query execution with undefined namespace should have failed."
+            )
+        except Exception:
+            # Expected failure
+            pass
+
+        # test with namespace at runtime
+        res_df = TestFrame2.run_query(simple_rdf_graph, initNs={"ns": example_ns})
+        assert len(
+            res_df
+        ), "Query failed to execute when running with namespace definition at runtime."
+
+        # test with initNs classattr
+        TestFrame2.initNs = {"ns": example_ns}
+        res_df = TestFrame2.run_query(simple_rdf_graph)
+        assert len(
+            res_df
+        ), "Query failed to execute when running with framer class namespace definition."
+
+        # # test with initNs classattr/runtime w/ same namespace (expect to fail)
+        # try:
+        #     _ = TestFrame2.run_query(simple_rdf_graph, initNs={"ns": example_ns})
+        #     raise Exception(
+        #         "Multiple occurances of the same namespace should trigger exception in framer."
+        #     )
+        # except Exception:
+        #     # Expected failure
+        #     pass
+
+    def test_framer_class_bindings(self, simple_rdf_graph, example_ns):
+        """Test the framer to ensure that different configurations of the classBindings
+        (class attr, kwarg, etc.) provide proper SPARQL query execution.
+        """
+
+        # test without bindings (expect all triples)
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        assert len(res_df) == len(
+            simple_rdf_graph
+        ), "Expected query without bindings to return all triples in the graph."
+
+        # test with classBindings attr
+        TestFrame1.classBindings = {"s": example_ns.Protagonist}
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        exp_len = len(
+            list(simple_rdf_graph.triples((example_ns.Protagonist, None, None)))
+        )
+        assert (
+            len(res_df) == exp_len
+        ), "Query failed to execute when running with framer classBindings."
+
+        # Revert class attribute
+        TestFrame1.classBindings = {}
+
+    def test_framer_init_bindings(self, simple_rdf_graph, example_ns):
+        """Test the framer to ensure that different configurations of the initBindings
+        provide proper SPARQL query execution.
+        """
+
+        test_query_instance = TestFrame1()
+
+        # test without bindings already covered by test_framer_class_bindings()
+
+        # test with initBindings explicit kwarg
+        res_df = test_query_instance.run_query(
+            simple_rdf_graph, initBindings={"s": example_ns.Protagonist}
+        )
+        exp_len = len(
+            list(simple_rdf_graph.triples((example_ns.Protagonist, None, None)))
+        )
+        assert (
+            len(res_df) == exp_len
+        ), "Query failed to execute when running with explicit initBindings kwarg."
+
+        # test with initBindings passed as kwarg
+        res_df = test_query_instance.run_query(
+            simple_rdf_graph, s=example_ns.Protagonist
+        )
+        exp_len = len(
+            list(simple_rdf_graph.triples((example_ns.Protagonist, None, None)))
+        )
+        assert (
+            len(res_df) == exp_len
+        ), "Query failed to execute when running with initBindings as kwargs."
+
+    def test_framer_columns(self, simple_rdf_graph):
+        """Test to make sure columns are set successfully."""
+
+        # Test default columns
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        assert " ".join(list(res_df.columns)) == "s p o"
+
+        # Test custom columns
+        res_df = TestFrame3.run_query(simple_rdf_graph)
+        assert " ".join(list(res_df.columns)) == " ".join(TestFrame3.columns)
+
+    def test_framer_index(self, simple_rdf_graph):
+        """Test setting an index for the DataFrame results on the framer."""
+
+        # test simple index
+        TestFrame1.index = ["s"]
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        assert len(list(res_df.columns)) == 2, "Failed to set index on framer."
+
+        # Revert class attribute
+        TestFrame1.index = []
+        res_df = TestFrame1.run_query(simple_rdf_graph)
+        assert len(list(res_df.columns)) == 3, "Failed to revert index on framer."
+
+    def test_framer_query_parsing(self, simple_rdf_graph):
+        """Verify that query parsing for repeat query execution is working.
+
+        TODO test that the query is re-parsed when the namespace or sparql string is changed
+        """
+        assert (
+            TestFrame4.query is None and TestFrame4.p_sparql == ""
+        ), "Test query class was executed prior to testing."
+
+        res_df = TestFrame4.run_query(simple_rdf_graph)
+        assert len(res_df) == 1, "Failed to return the expected query results."
+        assert (
+            TestFrame4.query is not None
+        ), "Failed to store the parsed query on the framer class."
+        assert (
+            TestFrame4.p_sparql != ""
+        ), "Failed to store the previous sparql state on the framer class"
+
+        # Run to make sure the execution doesn't throw errors when using the parsed query attribute
+        res_df = TestFrame4.run_query(simple_rdf_graph)
+        assert len(res_df) == 1, "Failed to return the expected query results."
+
+
+class TestFramerMetaclass:
+    # TODO how to move fixture to class attribute? pytest auto_use?
+
+    def test_framer_metaclass_without_values(self, simple_rdf_graph):
+        try:
+            _ = TestFrame5.run_query(simple_rdf_graph)
+            raise Exception("Expected a query with undefined VALUES to fail.")
+        except AssertionError:
+            # Expected this to fail
+            pass
+
+    def test_framer_metaclass_with_values(self, simple_rdf_graph, SCHEMA):
+        TestFrame5.values = {"type": [SCHEMA.Person]}
+        res_df = TestFrame5.run_query(simple_rdf_graph)
+        assert (
+            len(set(res_df.type)) == 1
+        ), "Failed to return query results when applying VALUES to metaclass."
+
+        # Revert class attribute
+        TestFrame5.values = None