From 3cb5bbd481979f5e3af3f47120bf7e96bc32611a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:44:29 +0000 Subject: [PATCH 1/4] Initial plan From a5788562f5367d7c03afe1f0525b655469b784ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:52:30 +0000 Subject: [PATCH 2/4] Add GSP endpoint configuration support with backward compatibility Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- tests/unit/test_gsp_endpoint_configuration.py | 274 ++++++++++++++++++ whyis/database/database_utils.py | 18 +- 2 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_gsp_endpoint_configuration.py diff --git a/tests/unit/test_gsp_endpoint_configuration.py b/tests/unit/test_gsp_endpoint_configuration.py new file mode 100644 index 000000000..ab1a92796 --- /dev/null +++ b/tests/unit/test_gsp_endpoint_configuration.py @@ -0,0 +1,274 @@ +""" +Unit tests for GSP endpoint configuration in database_utils module. + +Tests that Graph Store Protocol (GSP) endpoint can be configured separately +from the SPARQL query endpoint. +""" + +import pytest + +# Skip all tests if dependencies not available +pytest.importorskip("flask_security") + +from unittest.mock import Mock, patch, MagicMock +from rdflib import URIRef, ConjunctiveGraph, Graph +from whyis.database.database_utils import sparql_driver, _remote_sparql_store_protocol + + +class TestGSPEndpointConfiguration: + """Test GSP endpoint configuration functionality.""" + + def test_sparql_driver_with_separate_gsp_endpoint(self): + """Test that sparql_driver sets gsp_endpoint when provided in config.""" + config = { + "_endpoint": "http://localhost:3030/dataset/sparql", + "_gsp_endpoint": "http://localhost:3030/dataset/data" + } + + graph = sparql_driver(config) + + # Verify the store has both endpoints set correctly + assert graph.store.query_endpoint == "http://localhost:3030/dataset/sparql" + assert graph.store.gsp_endpoint == "http://localhost:3030/dataset/data" + + def test_sparql_driver_without_gsp_endpoint_falls_back_to_query_endpoint(self): + """Test that sparql_driver falls back to query_endpoint when gsp_endpoint not provided.""" + config = { + "_endpoint": "http://localhost:3030/dataset/sparql" + } + + graph = sparql_driver(config) + + # Verify the store uses query_endpoint for gsp_endpoint when not specified + assert graph.store.query_endpoint == "http://localhost:3030/dataset/sparql" + assert graph.store.gsp_endpoint == "http://localhost:3030/dataset/sparql" + + def test_sparql_driver_with_auth_credentials(self): + """Test that sparql_driver properly configures auth with separate gsp_endpoint.""" + config = { + "_endpoint": "http://localhost:3030/dataset/sparql", + "_gsp_endpoint": "http://localhost:3030/dataset/data", + "_username": "user", + "_password": "pass" + } + + graph = sparql_driver(config) + + # Verify auth is set + assert graph.store.auth == ("user", "pass") + assert graph.store.gsp_endpoint == "http://localhost:3030/dataset/data" + + def test_sparql_driver_with_default_graph(self): + """Test that sparql_driver handles default_graph with gsp_endpoint.""" + config = { + "_endpoint": "http://localhost:3030/dataset/sparql", + "_gsp_endpoint": "http://localhost:3030/dataset/data", + "_default_graph": "http://example.com/graph" + } + + graph = sparql_driver(config) + + # Verify the graph is a ConjunctiveGraph + assert isinstance(graph, ConjunctiveGraph) + assert graph.store.gsp_endpoint == "http://localhost:3030/dataset/data" + + +class TestRemoteSPARQLStoreProtocolGSP: + """Test that GSP operations use the gsp_endpoint.""" + + @patch('whyis.database.database_utils.requests.session') + def test_publish_uses_gsp_endpoint(self, mock_session): + """Test that publish operation uses gsp_endpoint.""" + mock_response = Mock() + mock_response.ok = True + mock_session_instance = Mock() + mock_session_instance.post.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a mock store with different endpoints + store = Mock() + store.query_endpoint = "http://localhost:3030/dataset/sparql" + store.gsp_endpoint = "http://localhost:3030/dataset/data" + store.auth = None + + # Apply the protocol + store = _remote_sparql_store_protocol(store) + + # Call publish + test_data = " ." + store.publish(test_data) + + # Verify POST was called with gsp_endpoint, not query_endpoint + mock_session_instance.post.assert_called_once() + call_args = mock_session_instance.post.call_args + assert call_args[0][0] == "http://localhost:3030/dataset/data" + + @patch('whyis.database.database_utils.requests.session') + def test_put_uses_gsp_endpoint(self, mock_session): + """Test that put operation uses gsp_endpoint.""" + mock_response = Mock() + mock_response.ok = True + mock_response.text = "Success" + mock_response.status_code = 200 + mock_session_instance = Mock() + mock_session_instance.put.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a mock store with different endpoints + store = Mock() + store.query_endpoint = "http://localhost:3030/dataset/sparql" + store.gsp_endpoint = "http://localhost:3030/dataset/data" + store.auth = None + + # Apply the protocol + store = _remote_sparql_store_protocol(store) + + # Create a mock graph + mock_graph = Mock() + mock_graph.identifier = URIRef("http://example.com/graph") + mock_graph.store = store + + # Mock ConjunctiveGraph and serialize + with patch('whyis.database.database_utils.ConjunctiveGraph') as mock_cg: + mock_cg_instance = Mock() + mock_cg_instance.serialize.return_value = "serialized data" + mock_cg.return_value = mock_cg_instance + + # Call put + store.put(mock_graph) + + # Verify PUT was called with gsp_endpoint + mock_session_instance.put.assert_called_once() + call_args = mock_session_instance.put.call_args + assert call_args[0][0] == "http://localhost:3030/dataset/data" + + @patch('whyis.database.database_utils.requests.session') + def test_post_uses_gsp_endpoint(self, mock_session): + """Test that post operation uses gsp_endpoint.""" + mock_response = Mock() + mock_response.ok = True + mock_session_instance = Mock() + mock_session_instance.post.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a mock store with different endpoints + store = Mock() + store.query_endpoint = "http://localhost:3030/dataset/sparql" + store.gsp_endpoint = "http://localhost:3030/dataset/data" + store.auth = None + + # Apply the protocol + store = _remote_sparql_store_protocol(store) + + # Create a mock graph + mock_graph = Mock() + mock_graph.store = store + + # Mock ConjunctiveGraph and serialize + with patch('whyis.database.database_utils.ConjunctiveGraph') as mock_cg: + mock_cg_instance = Mock() + mock_cg_instance.serialize.return_value = "serialized data" + mock_cg.return_value = mock_cg_instance + + # Call post + store.post(mock_graph) + + # Verify POST was called with gsp_endpoint + mock_session_instance.post.assert_called_once() + call_args = mock_session_instance.post.call_args + assert call_args[0][0] == "http://localhost:3030/dataset/data" + + @patch('whyis.database.database_utils.requests.session') + def test_delete_uses_gsp_endpoint(self, mock_session): + """Test that delete operation uses gsp_endpoint.""" + mock_response = Mock() + mock_response.ok = True + mock_session_instance = Mock() + mock_session_instance.delete.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a mock store with different endpoints + store = Mock() + store.query_endpoint = "http://localhost:3030/dataset/sparql" + store.gsp_endpoint = "http://localhost:3030/dataset/data" + store.auth = None + + # Apply the protocol + store = _remote_sparql_store_protocol(store) + + # Call delete + store.delete(URIRef("http://example.com/graph")) + + # Verify DELETE was called with gsp_endpoint + mock_session_instance.delete.assert_called_once() + call_args = mock_session_instance.delete.call_args + assert call_args[0][0] == "http://localhost:3030/dataset/data" + + @patch('whyis.database.database_utils.requests.session') + def test_operations_with_auth(self, mock_session): + """Test that GSP operations include auth when configured.""" + mock_response = Mock() + mock_response.ok = True + mock_session_instance = Mock() + mock_session_instance.post.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a mock store with auth + store = Mock() + store.query_endpoint = "http://localhost:3030/dataset/sparql" + store.gsp_endpoint = "http://localhost:3030/dataset/data" + store.auth = ("user", "password") + + # Apply the protocol + store = _remote_sparql_store_protocol(store) + + # Call publish + test_data = " ." + store.publish(test_data) + + # Verify auth was passed + call_kwargs = mock_session_instance.post.call_args[1] + assert 'auth' in call_kwargs + assert call_kwargs['auth'] == ("user", "password") + + +class TestBackwardCompatibility: + """Test that existing configurations without gsp_endpoint continue to work.""" + + def test_existing_config_without_gsp_endpoint_works(self): + """Test that configurations without _gsp_endpoint still work (backward compatibility).""" + config = { + "_endpoint": "http://localhost:3030/dataset/sparql" + } + + # This should not raise an error + graph = sparql_driver(config) + + # Both endpoints should be the same (fallback behavior) + assert graph.store.query_endpoint == graph.store.gsp_endpoint + assert graph.store.gsp_endpoint == "http://localhost:3030/dataset/sparql" + + @patch('whyis.database.database_utils.requests.session') + def test_gsp_operations_work_with_fallback_endpoint(self, mock_session): + """Test that GSP operations work when using fallback endpoint.""" + mock_response = Mock() + mock_response.ok = True + mock_session_instance = Mock() + mock_session_instance.post.return_value = mock_response + mock_session.return_value = mock_session_instance + + # Create a store with only query_endpoint (simulating old config) + config = { + "_endpoint": "http://localhost:3030/dataset/sparql" + } + graph = sparql_driver(config) + + # Verify the store has gsp_endpoint set to query_endpoint + assert graph.store.gsp_endpoint == graph.store.query_endpoint + + # Call publish to ensure it works + test_data = " ." + graph.store.publish(test_data) + + # Verify POST was called (should work without errors) + mock_session_instance.post.assert_called_once() diff --git a/whyis/database/database_utils.py b/whyis/database/database_utils.py index d874313c6..839426ff2 100644 --- a/whyis/database/database_utils.py +++ b/whyis/database/database_utils.py @@ -88,9 +88,9 @@ def publish(data, format='text/trig;charset=utf-8'): ) if store.auth is not None: kwargs['auth'] = store.auth - r = s.post(store.query_endpoint, data=data, **kwargs) + r = s.post(store.gsp_endpoint, data=data, **kwargs) if not r.ok: - print(f"Error: {store.query_endpoint} publish returned status {r.status_code}:\n{r.text}") + print(f"Error: {store.gsp_endpoint} publish returned status {r.status_code}:\n{r.text}") def put(graph): g = ConjunctiveGraph(store=graph.store) @@ -104,12 +104,12 @@ def put(graph): ) if store.auth is not None: kwargs['auth'] = store.auth - r = s.put(store.query_endpoint, + r = s.put(store.gsp_endpoint, params=dict(graph=graph.identifier), data=data, **kwargs) if not r.ok: - print(f"Error: {store.query_endpoint} PUT returned status {r.status_code}:\n{r.text}") + print(f"Error: {store.gsp_endpoint} PUT returned status {r.status_code}:\n{r.text}") else: print(r.text, r.status_code) @@ -124,9 +124,9 @@ def post(graph): ) if store.auth is not None: kwargs['auth'] = store.auth - r = s.post(store.query_endpoint, data=data, **kwargs) + r = s.post(store.gsp_endpoint, data=data, **kwargs) if not r.ok: - print(f"Error: {store.query_endpoint} POST returned status {r.status_code}:\n{r.text}") + print(f"Error: {store.gsp_endpoint} POST returned status {r.status_code}:\n{r.text}") def delete(c): s = requests.session() @@ -136,11 +136,11 @@ def delete(c): ) if store.auth is not None: kwargs['auth'] = store.auth - r = s.delete(store.query_endpoint, + r = s.delete(store.gsp_endpoint, params=dict(graph=c), **kwargs) if not r.ok: - print(f"Error: {store.query_endpoint} DELETE returned status {r.status_code}:\n{r.text}") + print(f"Error: {store.gsp_endpoint} DELETE returned status {r.status_code}:\n{r.text}") store.publish = publish store.put = put @@ -164,6 +164,8 @@ def sparql_driver(config): kwargs['auth'] = (config['_username'], config['_password']) store = WhyisSPARQLUpdateStore(**kwargs) store.query_endpoint = config["_endpoint"] + # Set GSP endpoint: use _gsp_endpoint if provided, otherwise fall back to query_endpoint + store.gsp_endpoint = config.get("_gsp_endpoint", config["_endpoint"]) if 'auth' in kwargs: store.auth = kwargs['auth'] else: From 9198c88c8b3923ea45ef074866483c4784c726c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:53:56 +0000 Subject: [PATCH 3/4] Add comprehensive documentation for GSP endpoint configuration Co-authored-by: jpmccu <602385+jpmccu@users.noreply.github.com> --- whyis/database/database_utils.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/whyis/database/database_utils.py b/whyis/database/database_utils.py index 839426ff2..a36573558 100644 --- a/whyis/database/database_utils.py +++ b/whyis/database/database_utils.py @@ -79,6 +79,20 @@ def oxigraph_driver(config): return graph def _remote_sparql_store_protocol(store): + """ + Add Graph Store Protocol (GSP) operations to a SPARQL store. + + This function adds publish, put, post, and delete methods to the store + that use the store.gsp_endpoint for all HTTP operations. The gsp_endpoint + should be configured separately from query_endpoint to allow different + endpoints for SPARQL queries vs. graph manipulation. + + Args: + store: A SPARQL store object with gsp_endpoint and auth attributes + + Returns: + The store object with GSP methods attached + """ def publish(data, format='text/trig;charset=utf-8'): s = requests.session() s.keep_alive = False @@ -150,6 +164,24 @@ def delete(c): @driver(name="sparql") def sparql_driver(config): + """ + Create a SPARQL-based RDF graph store. + + Configuration options (via Flask config with prefix like KNOWLEDGE_ or ADMIN_): + - _endpoint: SPARQL query/update endpoint (required) + - _gsp_endpoint: Graph Store Protocol endpoint (optional, defaults to _endpoint) + - _username: Authentication username (optional) + - _password: Authentication password (optional) + - _default_graph: Default graph URI (optional) + + Example configuration in system.conf: + KNOWLEDGE_ENDPOINT = 'http://localhost:3030/knowledge/sparql' + KNOWLEDGE_GSP_ENDPOINT = 'http://localhost:3030/knowledge/data' # optional + + If _gsp_endpoint is not provided, all Graph Store Protocol operations + (publish, put, post, delete) will use the _endpoint value, maintaining + backward compatibility with existing configurations. + """ defaultgraph = None if "_default_graph" in config: defaultgraph = URIRef(config["_default_graph"]) From be2cab4f2e11d5887728e8d461774ea864877c02 Mon Sep 17 00:00:00 2001 From: Jamie McCusker Date: Thu, 18 Dec 2025 23:04:53 -0500 Subject: [PATCH 4/4] removed private import --- whyis/database/whyis_sparql_update_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/whyis/database/whyis_sparql_update_store.py b/whyis/database/whyis_sparql_update_store.py index 1342137e3..bd61c6edb 100644 --- a/whyis/database/whyis_sparql_update_store.py +++ b/whyis/database/whyis_sparql_update_store.py @@ -1,7 +1,7 @@ # -*- coding:utf-8 -*- from rdflib.plugins.stores.sparqlstore import SPARQLUpdateStore -from rdflib.plugins.stores.sparqlconnector import SPARQLConnectorException, _response_mime_types +#from rdflib.plugins.stores.sparqlconnector import SPARQLConnectorException, _response_mime_types import re class WhyisSPARQLUpdateStore(SPARQLUpdateStore):