Skip to content

Commit 98e7e1f

Browse files
committed
feat: add HTTP method selection with GET default for read queries [release]
1 parent f1293ec commit 98e7e1f

9 files changed

Lines changed: 169 additions & 81 deletions

File tree

.coverage

0 Bytes
Binary file not shown.

docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default defineConfig({
4343
{
4444
label: 'Configuration',
4545
items: [
46+
{ label: 'HTTP methods', slug: 'configuration/http-methods' },
4647
{ label: 'Connection pooling', slug: 'configuration/connection-pooling' },
4748
{ label: 'Retry settings', slug: 'configuration/retry-settings' },
4849
],
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
title: HTTP methods
3+
description: Configure GET and POST for SPARQL requests
4+
---
5+
6+
import { Aside } from '@astrojs/starlight/components';
7+
8+
sparqlite supports both GET and POST HTTP methods for SPARQL requests, following the SPARQL 1.1 Protocol specification.
9+
10+
## Default methods
11+
12+
Each query type uses a default HTTP method:
13+
14+
| Query type | Default method | Reason |
15+
|------------|---------------|--------|
16+
| SELECT | GET | Read operation, cacheable |
17+
| ASK | GET | Read operation, cacheable |
18+
| CONSTRUCT | GET | Read operation, cacheable |
19+
| DESCRIBE | GET | Read operation, cacheable |
20+
| UPDATE | POST | Write operation (required by spec) |
21+
22+
GET requests pass the query as a URL parameter (`?query=...`). POST requests pass the query in the request body as form-encoded data.
23+
24+
## Overriding the default method
25+
26+
All read query methods accept a `method` keyword argument:
27+
28+
```python
29+
from sparqlite import SPARQLClient
30+
31+
with SPARQLClient("https://opencitations.net/meta/sparql") as client:
32+
# Use POST for a long SELECT query that may exceed URL length limits
33+
result = client.query(long_query, method="POST")
34+
35+
# Use POST for ASK
36+
exists = client.ask(query, method="POST")
37+
38+
# Use POST for CONSTRUCT
39+
triples = client.construct(query, method="POST")
40+
41+
# Use POST for DESCRIBE
42+
data = client.describe(query, method="POST")
43+
```
44+
45+
<Aside type="note">
46+
The `update()` method always uses POST. The SPARQL 1.1 Protocol specification requires POST for update operations.
47+
</Aside>
48+
49+
## When to use POST for read queries
50+
51+
Use `method="POST"` when:
52+
53+
- The query string is very long and may exceed URL length limits (typically 2000-8000 characters depending on the server)
54+
- You want to avoid caching of results
55+
56+
```python
57+
with SPARQLClient("https://opencitations.net/meta/sparql") as client:
58+
long_query = "SELECT * WHERE { " + " UNION ".join(
59+
f"{{ ?s <http://example.org/p{i}> ?o }}" for i in range(100)
60+
) + " }"
61+
62+
# POST avoids URL length issues
63+
result = client.query(long_query, method="POST")
64+
```

docs/src/content/docs/getting-started/quick-start.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,6 @@ with SPARQLClient("https://opencitations.net/meta/sparql") as client:
123123
## Next steps
124124

125125
- Learn about [SELECT queries](/sparqlite/guides/select-queries/) in detail
126+
- Configure [HTTP methods](/sparqlite/configuration/http-methods/) (GET vs POST)
126127
- Understand [connection pooling](/sparqlite/configuration/connection-pooling/) for performance
127128
- Configure [retry settings](/sparqlite/configuration/retry-settings/) for reliability

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
dev = [
1717
"coverage>=7.10.7",
1818
"isort>=6.1.0",
19+
"pyright>=1.1.408",
1920
"pytest>=8.4.2",
2021
"pytest-cov>=7.0.0",
2122
"virtuoso-utilities>=1.6.1",

src/sparqlite/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""sparqlite - a modern, lightweight SPARQL 1.1 client for Python."""
22

3-
from sparqlite.client import SPARQLClient
3+
from sparqlite.client import HTTPMethod, SPARQLClient
44
from sparqlite.exceptions import EndpointError, QueryError, SPARQLError
55

66
__version__ = "0.1.0"
77

88
__all__ = [
9+
"HTTPMethod",
910
"SPARQLClient",
1011
"SPARQLError",
1112
"QueryError",

src/sparqlite/client.py

Lines changed: 45 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
import time
55
import warnings
66
from io import BytesIO
7+
from typing import Literal
78
from urllib.parse import urlencode
89

910
import pycurl
1011

1112
from sparqlite.exceptions import EndpointError, QueryError
1213

14+
HTTPMethod = Literal["GET", "POST"]
15+
1316

1417
class SPARQLClient:
15-
"""Synchronous SPARQL 1.1 client with connection pooling and automatic retry."""
1618

1719
def __init__(
1820
self,
@@ -22,24 +24,16 @@ def __init__(
2224
backoff_factor: float = 0.5,
2325
timeout: float | None = None,
2426
):
25-
"""Initialize the SPARQL client.
26-
27-
Args:
28-
endpoint: The SPARQL endpoint URL.
29-
max_retries: Maximum number of retry attempts for transient errors.
30-
backoff_factor: Factor for exponential backoff (wait = factor * 2^retry).
31-
timeout: Request timeout in seconds. None means no timeout.
32-
"""
3327
self.endpoint = endpoint
3428
self.max_retries = max_retries
3529
self.backoff_factor = backoff_factor
3630
self.timeout = timeout
37-
self._curl = pycurl.Curl()
31+
self._curl: pycurl.Curl | None = pycurl.Curl()
3832

3933
def __enter__(self) -> "SPARQLClient":
4034
return self
4135

42-
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
36+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
4337
self.close()
4438

4539
def __del__(self) -> None:
@@ -53,10 +47,6 @@ def __del__(self) -> None:
5347
self.close()
5448

5549
def close(self) -> None:
56-
"""Close the client and release resources.
57-
58-
This method is idempotent - calling it multiple times is safe.
59-
"""
6050
if self._curl is not None:
6151
self._curl.close()
6252
self._curl = None
@@ -66,42 +56,49 @@ def _request(
6656
query: str,
6757
accept: str,
6858
*,
59+
method: HTTPMethod = "GET",
6960
is_update: bool = False,
7061
) -> bytes:
71-
"""Execute an HTTP request with retry logic."""
72-
last_error = None
62+
if self._curl is None:
63+
raise EndpointError("Client is closed")
64+
65+
curl = self._curl
66+
last_error: EndpointError | None = None
7367

7468
for attempt in range(self.max_retries + 1):
7569
if attempt > 0:
7670
wait_time = self.backoff_factor * (2**attempt)
7771
time.sleep(wait_time)
7872

7973
buffer = BytesIO()
80-
self._curl.reset()
74+
curl.reset()
8175

8276
if self.timeout is not None:
83-
self._curl.setopt(pycurl.TIMEOUT_MS, int(self.timeout * 1000))
77+
curl.setopt(pycurl.TIMEOUT_MS, int(self.timeout * 1000))
8478

8579
try:
86-
self._curl.setopt(pycurl.URL, self.endpoint)
87-
self._curl.setopt(pycurl.WRITEDATA, buffer)
88-
self._curl.setopt(
80+
param_key = "update" if is_update else "query"
81+
82+
if method == "GET":
83+
url = f"{self.endpoint}?{urlencode({param_key: query})}"
84+
curl.setopt(pycurl.URL, url)
85+
curl.setopt(pycurl.HTTPGET, 1)
86+
else:
87+
curl.setopt(pycurl.URL, self.endpoint)
88+
curl.setopt(pycurl.POSTFIELDS, urlencode({param_key: query}))
89+
90+
curl.setopt(pycurl.WRITEDATA, buffer)
91+
curl.setopt(
8992
pycurl.HTTPHEADER,
9093
[
9194
f"Accept: {accept}",
9295
"User-Agent: sparqlite/0.1.0",
9396
],
9497
)
9598

96-
if is_update:
97-
post_data = urlencode({"update": query})
98-
else:
99-
post_data = urlencode({"query": query})
100-
101-
self._curl.setopt(pycurl.POSTFIELDS, post_data)
102-
self._curl.perform()
99+
curl.perform()
103100

104-
status_code = self._curl.getinfo(pycurl.RESPONSE_CODE)
101+
status_code = curl.getinfo(pycurl.RESPONSE_CODE)
105102

106103
if status_code == 400:
107104
raise QueryError(f"Query syntax error: {buffer.getvalue().decode()}")
@@ -131,62 +128,30 @@ def _request(
131128
last_error = EndpointError(f"Request error: {error_msg}")
132129
continue
133130

134-
raise last_error
135-
136-
def query(self, query: str) -> dict:
137-
"""Execute a SELECT query.
131+
raise last_error # type: ignore[misc]
138132

139-
Args:
140-
query: The SPARQL SELECT query string.
141-
142-
Returns:
143-
Dictionary with SPARQL JSON results format.
144-
"""
145-
content = self._request(query, "application/sparql-results+json")
133+
def query(self, query: str, *, method: HTTPMethod = "GET") -> dict:
134+
content = self._request(
135+
query, "application/sparql-results+json", method=method
136+
)
146137
return json.loads(content)
147138

148-
def select(self, query: str) -> dict:
149-
"""Execute a SELECT query. Alias for query()."""
150-
return self.query(query)
151-
152-
def ask(self, query: str) -> bool:
153-
"""Execute an ASK query.
139+
def select(self, query: str, *, method: HTTPMethod = "GET") -> dict:
140+
return self.query(query, method=method)
154141

155-
Args:
156-
query: The SPARQL ASK query string.
157-
158-
Returns:
159-
Boolean result of the ASK query.
160-
"""
161-
content = self._request(query, "application/sparql-results+json")
142+
def ask(self, query: str, *, method: HTTPMethod = "GET") -> bool:
143+
content = self._request(
144+
query, "application/sparql-results+json", method=method
145+
)
162146
return json.loads(content)["boolean"]
163147

164-
def construct(self, query: str) -> bytes:
165-
"""Execute a CONSTRUCT query.
166-
167-
Args:
168-
query: The SPARQL CONSTRUCT query string.
148+
def construct(self, query: str, *, method: HTTPMethod = "GET") -> bytes:
149+
return self._request(query, "application/n-triples", method=method)
169150

170-
Returns:
171-
Raw N-Triples bytes.
172-
"""
173-
return self._request(query, "application/n-triples")
174-
175-
def describe(self, query: str) -> bytes:
176-
"""Execute a DESCRIBE query.
177-
178-
Args:
179-
query: The SPARQL DESCRIBE query string.
180-
181-
Returns:
182-
Raw N-Triples bytes.
183-
"""
184-
return self._request(query, "application/n-triples")
151+
def describe(self, query: str, *, method: HTTPMethod = "GET") -> bytes:
152+
return self._request(query, "application/n-triples", method=method)
185153

186154
def update(self, query: str) -> None:
187-
"""Execute a SPARQL UPDATE query (INSERT, DELETE, etc.).
188-
189-
Args:
190-
query: The SPARQL UPDATE query string.
191-
"""
192-
self._request(query, "application/sparql-results+json", is_update=True)
155+
self._request(
156+
query, "application/sparql-results+json", method="POST", is_update=True
157+
)

tests/test_error_handling.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,34 @@ def track_setopt(opt, val):
185185

186186
timeout_calls = [opt for opt, val in setopt_calls if opt == pycurl.TIMEOUT_MS]
187187
assert timeout_calls == []
188+
189+
190+
class TestHTTPMethod:
191+
192+
def test_post_method_uses_postfields(self):
193+
mock_curl = MagicMock()
194+
mock_curl.getinfo.return_value = 200
195+
setopt_calls: list[tuple[int, object]] = []
196+
197+
def track_setopt(opt: int, val: object) -> None:
198+
setopt_calls.append((opt, val))
199+
200+
mock_curl.setopt = track_setopt
201+
202+
with patch("pycurl.Curl", return_value=mock_curl):
203+
client = SPARQLClient("http://example.org/sparql")
204+
client._curl = mock_curl
205+
client._request("SELECT ?s WHERE { ?s ?p ?o }", "application/json", method="POST")
206+
client.close()
207+
208+
assert any(opt == pycurl.POSTFIELDS for opt, _ in setopt_calls)
209+
assert not any(opt == pycurl.HTTPGET for opt, _ in setopt_calls)
210+
211+
def test_request_on_closed_client_raises(self):
212+
with patch("pycurl.Curl") as mock_curl_class:
213+
mock_curl_class.return_value = MagicMock()
214+
client = SPARQLClient("http://example.org/sparql")
215+
client.close()
216+
217+
with pytest.raises(EndpointError):
218+
client._request("SELECT ?s WHERE { ?s ?p ?o }", "application/json")

uv.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)