From 400f82a293fb6a5d324a876edfba7027d9c331e9 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Fri, 19 Dec 2025 19:36:47 +0100 Subject: [PATCH 1/4] feat: introduce QueryParseException The change adds a SPARQLParseException sub-exception: QueryParseException. This allows to differentiate between exceptions raised by SPARQL queries and SPARQL update requests. --- src/sparqlx/__init__.py | 3 ++- src/sparqlx/utils/utils.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/sparqlx/__init__.py b/src/sparqlx/__init__.py index a321a20..b99c382 100644 --- a/src/sparqlx/__init__.py +++ b/src/sparqlx/__init__.py @@ -8,11 +8,12 @@ SPARQLQueryTypeLiteral, SelectQuery, ) -from sparqlx.utils.utils import SPARQLParseException +from sparqlx.utils.utils import QueryParseException, SPARQLParseException __all__ = ( "SPARQLWrapper", "SPARQLParseException", + "QueryParseException", "AskQuery", "ConstructQuery", "DescribeQuery", diff --git a/src/sparqlx/utils/utils.py b/src/sparqlx/utils/utils.py index 84d0262..3b09887 100644 --- a/src/sparqlx/utils/utils.py +++ b/src/sparqlx/utils/utils.py @@ -1,6 +1,7 @@ from typing import cast from rdflib.plugins.sparql import prepareQuery +from rdflib.plugins.sparql.parser import parseUpdate from rdflib.plugins.sparql.sparql import Query from sparqlx.types import SPARQLQuery, SPARQLQueryTypeLiteral, SPARQLResponseFormat from sparqlx.utils.converters import _convert_ask, _convert_bindings, _convert_graph @@ -9,11 +10,14 @@ class SPARQLParseException(Exception): ... +class QueryParseException(SPARQLParseException): ... + + def _get_query_type(query: SPARQLQuery) -> SPARQLQueryTypeLiteral: try: _prepared_query: Query = prepareQuery(query) except Exception as exc: - raise SPARQLParseException(exc) from exc + raise QueryParseException(exc) from exc else: query_type = _prepared_query.algebra.name From efa6443ff4a31e78b2f26f1f805761ed77adfdc4 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Fri, 19 Dec 2025 19:39:53 +0100 Subject: [PATCH 2/4] feat: introduce update request parsing and UpdateParseException The commit introduces update request parsing. Update request strings passed to `sparqlx.SPARQLWrapper.update`/`.aupdate` are parsed using a side-effect helper that calls RDFLib `parseUpdate` and raises `sparqlx.UpdateParseException` on failure. Closes #89. --- src/sparqlx/__init__.py | 7 ++++++- src/sparqlx/sparqlwrapper.py | 10 +++++++++- src/sparqlx/utils/utils.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/sparqlx/__init__.py b/src/sparqlx/__init__.py index b99c382..0a8719d 100644 --- a/src/sparqlx/__init__.py +++ b/src/sparqlx/__init__.py @@ -8,12 +8,17 @@ SPARQLQueryTypeLiteral, SelectQuery, ) -from sparqlx.utils.utils import QueryParseException, SPARQLParseException +from sparqlx.utils.utils import ( + QueryParseException, + SPARQLParseException, + UpdateParseException, +) __all__ = ( "SPARQLWrapper", "SPARQLParseException", "QueryParseException", + "UpdateParseException", "AskQuery", "ConstructQuery", "DescribeQuery", diff --git a/src/sparqlx/sparqlwrapper.py b/src/sparqlx/sparqlwrapper.py index 66e4b11..8bb3e75 100644 --- a/src/sparqlx/sparqlwrapper.py +++ b/src/sparqlx/sparqlwrapper.py @@ -24,7 +24,11 @@ QueryOperationParameters, UpdateOperationParameters, ) -from sparqlx.utils.utils import _get_query_type, _get_response_converter +from sparqlx.utils.utils import ( + _get_query_type, + _get_response_converter, + _parse_udpate_request, +) class SPARQLWrapper(AbstractContextManager, AbstractAsyncContextManager): @@ -398,6 +402,8 @@ def update( using_graph_uri: RequestDataValue = None, using_named_graph_uri: RequestDataValue = None, ) -> httpx.Response: + _parse_udpate_request(update_request=update_request) + params = UpdateOperationParameters( update_request=update_request, version=version, @@ -421,6 +427,8 @@ async def aupdate( using_graph_uri: RequestDataValue = None, using_named_graph_uri: RequestDataValue = None, ) -> httpx.Response: + _parse_udpate_request(update_request=update_request) + params = UpdateOperationParameters( update_request=update_request, version=version, diff --git a/src/sparqlx/utils/utils.py b/src/sparqlx/utils/utils.py index 3b09887..8bdac62 100644 --- a/src/sparqlx/utils/utils.py +++ b/src/sparqlx/utils/utils.py @@ -13,6 +13,16 @@ class SPARQLParseException(Exception): ... class QueryParseException(SPARQLParseException): ... +class UpdateParseException(SPARQLParseException): ... + + +def _parse_udpate_request(update_request: str) -> None: + try: + parseUpdate(update_request) + except Exception as exc: + raise UpdateParseException(exc) from exc + + def _get_query_type(query: SPARQLQuery) -> SPARQLQueryTypeLiteral: try: _prepared_query: Query = prepareQuery(query) From 5f07c93f02b2d4cf79f53bd7ca439efc51c6bca3 Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Fri, 19 Dec 2025 20:06:29 +0100 Subject: [PATCH 3/4] feat: implement optional parsing --- src/sparqlx/sparqlwrapper.py | 57 +++++++++++++++++++++++++++----- src/sparqlx/utils/utils.py | 63 ++++++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/sparqlx/sparqlwrapper.py b/src/sparqlx/sparqlwrapper.py index 8bb3e75..a7033e5 100644 --- a/src/sparqlx/sparqlwrapper.py +++ b/src/sparqlx/sparqlwrapper.py @@ -46,10 +46,13 @@ def __init__( client_config: dict | None = None, aclient: httpx.AsyncClient | None = None, aclient_config: dict | None = None, + parse: bool = True, ) -> None: self.sparql_endpoint = sparql_endpoint self.update_endpoint = update_endpoint + self.parse = parse + self._client_manager = ClientManager( client=client, client_config=client_config, @@ -80,6 +83,7 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> list[SPARQLResultBinding]: ... @overload @@ -91,6 +95,7 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> bool: ... @overload @@ -102,6 +107,7 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Graph: ... @overload @@ -113,6 +119,7 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> list[SPARQLResultBinding] | Graph | bool: ... @overload @@ -124,6 +131,7 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response: ... def query( @@ -134,8 +142,10 @@ def query( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response | list[SPARQLResultBinding] | Graph | bool: - query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query) + _parse: bool = self.parse if parse is None else parse + query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query, parse=_parse) params = QueryOperationParameters( query=query, @@ -173,6 +183,7 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> list[SPARQLResultBinding]: ... @overload @@ -184,6 +195,7 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> bool: ... @overload @@ -195,6 +207,7 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Graph: ... @overload @@ -206,6 +219,7 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> list[SPARQLResultBinding] | Graph | bool: ... @overload @@ -217,6 +231,7 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response: ... async def aquery( @@ -227,8 +242,10 @@ async def aquery( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response | list[SPARQLResultBinding] | Graph | bool: - query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query) + _parse: bool = self.parse if parse is None else parse + query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query, parse=_parse) params = QueryOperationParameters( query=query, @@ -268,8 +285,10 @@ def query_stream[T]( [httpx.Response], Iterator[T] ] = httpx.Response.iter_bytes, chunk_size: int | None = None, + parse: bool | None = None, ) -> Iterator[T]: - query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query) + _parse: bool = self.parse if parse is None else parse + query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query, parse=_parse) params = QueryOperationParameters( query=query, @@ -309,8 +328,10 @@ async def aquery_stream[T]( [httpx.Response], AsyncIterator[T] ] = httpx.Response.aiter_bytes, chunk_size: int | None = None, + parse: bool | None = None, ) -> AsyncIterator[T]: - query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query) + _parse: bool = self.parse if parse is None else parse + query_type: SPARQLQueryTypeLiteral = _get_query_type(query=query, parse=_parse) params = QueryOperationParameters( query=query, @@ -348,6 +369,7 @@ def queries( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Iterator[list[SPARQLResultBinding] | Graph | bool]: ... @overload @@ -359,6 +381,7 @@ def queries( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Iterator[httpx.Response]: ... def queries( @@ -369,9 +392,14 @@ def queries( version: str | None = None, default_graph_uri: RequestDataValue = None, named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Iterator[httpx.Response | list[SPARQLResultBinding] | Graph | bool]: + _parse: bool = self.parse if parse is None else parse + query_component = SPARQLWrapper( - sparql_endpoint=self.sparql_endpoint, aclient=self._client_manager.aclient + sparql_endpoint=self.sparql_endpoint, + aclient=self._client_manager.aclient, + parse=_parse, ) async def _runner() -> Iterator[httpx.Response]: @@ -401,8 +429,12 @@ def update( version: str | None = None, using_graph_uri: RequestDataValue = None, using_named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response: - _parse_udpate_request(update_request=update_request) + _parse: bool = self.parse if parse is None else parse + + if _parse: + _parse_udpate_request(update_request=update_request) params = UpdateOperationParameters( update_request=update_request, @@ -426,8 +458,12 @@ async def aupdate( version: str | None = None, using_graph_uri: RequestDataValue = None, using_named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> httpx.Response: - _parse_udpate_request(update_request=update_request) + _parse: bool = self.parse if parse is None else parse + + if _parse: + _parse_udpate_request(update_request=update_request) params = UpdateOperationParameters( update_request=update_request, @@ -451,9 +487,14 @@ def updates( version: str | None = None, using_graph_uri: RequestDataValue = None, using_named_graph_uri: RequestDataValue = None, + parse: bool | None = None, ) -> Iterator[httpx.Response]: + _parse: bool = self.parse if parse is None else parse + update_component = SPARQLWrapper( - update_endpoint=self.update_endpoint, aclient=self._client_manager.aclient + update_endpoint=self.update_endpoint, + aclient=self._client_manager.aclient, + parse=_parse, ) async def _runner() -> Iterator[httpx.Response]: diff --git a/src/sparqlx/utils/utils.py b/src/sparqlx/utils/utils.py index 8bdac62..dcf8777 100644 --- a/src/sparqlx/utils/utils.py +++ b/src/sparqlx/utils/utils.py @@ -1,9 +1,17 @@ -from typing import cast +from typing import TypeGuard, get_args from rdflib.plugins.sparql import prepareQuery from rdflib.plugins.sparql.parser import parseUpdate from rdflib.plugins.sparql.sparql import Query -from sparqlx.types import SPARQLQuery, SPARQLQueryTypeLiteral, SPARQLResponseFormat +from sparqlx.types import ( + AskQuery, + ConstructQuery, + DescribeQuery, + SPARQLQuery, + SPARQLQueryTypeLiteral, + SPARQLResponseFormat, + SelectQuery, +) from sparqlx.utils.converters import _convert_ask, _convert_bindings, _convert_graph @@ -23,15 +31,50 @@ def _parse_udpate_request(update_request: str) -> None: raise UpdateParseException(exc) from exc -def _get_query_type(query: SPARQLQuery) -> SPARQLQueryTypeLiteral: - try: - _prepared_query: Query = prepareQuery(query) - except Exception as exc: - raise QueryParseException(exc) from exc +def _is_sparql_query_type_literal(value) -> TypeGuard[SPARQLQueryTypeLiteral]: + return value in get_args(SPARQLQueryTypeLiteral.__value__) + + +def _get_query_type(query: SPARQLQuery, parse: bool) -> SPARQLQueryTypeLiteral: + def _from_typed_query( + query: SelectQuery | AskQuery | ConstructQuery | DescribeQuery, + ) -> SPARQLQueryTypeLiteral: + match query: + case SelectQuery(): + query_type = "SelectQuery" + case AskQuery(): + query_type = "AskQuery" + case ConstructQuery(): + query_type = "ConstructQuery" + case DescribeQuery(): + query_type = "DescribeQuery" + case _: # pragma: no cover + assert False, "This should never happen." + + return query_type + + def _from_parsed_query(query: str) -> SPARQLQueryTypeLiteral: + try: + _prepared_query: Query = prepareQuery(query) + except Exception as exc: + raise QueryParseException(exc) from exc + else: + query_type = _prepared_query.algebra.name + + assert _is_sparql_query_type_literal(query_type) + return query_type + + is_typed_query: bool = isinstance( + query, SelectQuery | AskQuery | ConstructQuery | DescribeQuery + ) + + if not is_typed_query and not parse: + msg = "Query must be of type SelectQuery | AskQuery | ConstructQuery | DescribeQuery if parse=False." + raise ValueError(msg) + elif is_typed_query and not parse: + return _from_typed_query(query) else: - query_type = _prepared_query.algebra.name - - return cast(SPARQLQueryTypeLiteral, query_type) + return _from_parsed_query(query) def _get_response_converter( From 9dece0b73acbf5415fee4a77f89931f0c4bfd68d Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Sat, 20 Dec 2025 15:45:58 +0100 Subject: [PATCH 4/4] test: implement tests for optional query parsing --- .../test_sparqlwrapper_optional_parsing.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/test_sparqlwrapper/test_sparqlwrapper_optional_parsing.py diff --git a/tests/test_sparqlwrapper/test_sparqlwrapper_optional_parsing.py b/tests/test_sparqlwrapper/test_sparqlwrapper_optional_parsing.py new file mode 100644 index 0000000..1f9ff0c --- /dev/null +++ b/tests/test_sparqlwrapper/test_sparqlwrapper_optional_parsing.py @@ -0,0 +1,121 @@ +from typing import NamedTuple + +import httpx +import pytest +from sparqlx import QueryParseException, SPARQLWrapper, UpdateParseException +from sparqlx.types import ( + AskQuery, + ConstructQuery, + DescribeQuery, + SPARQLQuery, + SelectQuery, +) + +from utils import acall + + +class OptionalParseParameters(NamedTuple): + exception: type[Exception] + invalid_sparql: str | SPARQLQuery = "INVALID" + + wrapper_parse: bool = True + method_parse: bool | None = None + + +query_params = [ + OptionalParseParameters(exception=QueryParseException), + OptionalParseParameters( + invalid_sparql=SelectQuery("INVALID"), + exception=httpx.HTTPStatusError, + wrapper_parse=False, + ), + OptionalParseParameters( + invalid_sparql=AskQuery("INVALID"), + exception=httpx.HTTPStatusError, + wrapper_parse=False, + ), + OptionalParseParameters( + invalid_sparql=ConstructQuery("INVALID"), + exception=httpx.HTTPStatusError, + wrapper_parse=False, + ), + OptionalParseParameters( + invalid_sparql=DescribeQuery("INVALID"), + exception=httpx.HTTPStatusError, + wrapper_parse=False, + ), + OptionalParseParameters(exception=ValueError, wrapper_parse=False), + OptionalParseParameters( + exception=QueryParseException, wrapper_parse=False, method_parse=True + ), + OptionalParseParameters(exception=QueryParseException, method_parse=True), +] + + +@pytest.mark.parametrize("method", ["query", "aquery"]) +@pytest.mark.parametrize("param", query_params) +@pytest.mark.parametrize("managed_client", [True, False]) +@pytest.mark.asyncio +async def test_sparqlwrapper_query_optional_parse( + method, param, triplestore, managed_client +): + sparql_endpoint: str = triplestore.sparql_endpoint + + client, aclient = ( + (httpx.Client(), httpx.AsyncClient()) if managed_client else (None, None) + ) + + sparqlwrapper = SPARQLWrapper( + sparql_endpoint=sparql_endpoint, + client=client, + aclient=aclient, + parse=param.wrapper_parse, + ) + + with pytest.raises(param.exception): + await acall( + sparqlwrapper, method, query=param.invalid_sparql, parse=param.method_parse + ) + + +update_params = [ + OptionalParseParameters(exception=UpdateParseException), + OptionalParseParameters( + exception=UpdateParseException, wrapper_parse=False, method_parse=True + ), + OptionalParseParameters(exception=UpdateParseException, method_parse=True), + OptionalParseParameters(exception=httpx.HTTPStatusError, wrapper_parse=False), + OptionalParseParameters(exception=httpx.HTTPStatusError, method_parse=False), + OptionalParseParameters( + exception=UpdateParseException, wrapper_parse=False, method_parse=True + ), +] + + +@pytest.mark.parametrize("method", ["update", "aupdate"]) +@pytest.mark.parametrize("param", update_params) +@pytest.mark.parametrize("managed_client", [True, False]) +@pytest.mark.asyncio +async def test_sparqlwrapper_update_optional_parse( + method, param, triplestore, managed_client +): + update_endpoint: str = triplestore.update_endpoint + + client, aclient = ( + (httpx.Client(), httpx.AsyncClient()) if managed_client else (None, None) + ) + + sparqlwrapper = SPARQLWrapper( + update_endpoint=update_endpoint, + client=client, + aclient=aclient, + parse=param.wrapper_parse, + ) + + with pytest.raises(param.exception): + await acall( + sparqlwrapper, + method, + update_request=param.invalid_sparql, + parse=param.method_parse, + )