rdf4j-python 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rdf4j_python/__init__.py +71 -4
- rdf4j_python/_client/_client.py +127 -66
- rdf4j_python/_driver/__init__.py +4 -0
- rdf4j_python/_driver/_async_rdf4j_db.py +40 -6
- rdf4j_python/_driver/_async_repository.py +249 -18
- rdf4j_python/_driver/_async_transaction.py +310 -0
- rdf4j_python/exception/__init__.py +29 -1
- rdf4j_python/exception/repo_exception.py +49 -22
- rdf4j_python/model/__init__.py +26 -0
- rdf4j_python/model/term.py +15 -0
- {rdf4j_python-0.1.6.dist-info → rdf4j_python-0.2.0.dist-info}/METADATA +82 -28
- rdf4j_python-0.2.0.dist-info/RECORD +24 -0
- {rdf4j_python-0.1.6.dist-info → rdf4j_python-0.2.0.dist-info}/WHEEL +1 -1
- rdf4j_python-0.1.6.dist-info/RECORD +0 -23
- {rdf4j_python-0.1.6.dist-info → rdf4j_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {rdf4j_python-0.1.6.dist-info → rdf4j_python-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Iterable, Optional, Union
|
|
2
4
|
|
|
3
5
|
import httpx
|
|
4
6
|
import pyoxigraph as og
|
|
5
7
|
|
|
6
8
|
from rdf4j_python._client import AsyncApiClient
|
|
7
9
|
from rdf4j_python._driver._async_named_graph import AsyncNamedGraph
|
|
10
|
+
from rdf4j_python._driver._async_transaction import AsyncTransaction, IsolationLevel
|
|
8
11
|
from rdf4j_python.exception.repo_exception import (
|
|
9
12
|
NamespaceException,
|
|
10
13
|
RepositoryInternalException,
|
|
@@ -26,13 +29,91 @@ from rdf4j_python.utils.const import Rdf4jContentType
|
|
|
26
29
|
from rdf4j_python.utils.helpers import serialize_statements
|
|
27
30
|
|
|
28
31
|
try:
|
|
29
|
-
from SPARQLWrapper import SPARQLWrapper
|
|
32
|
+
from SPARQLWrapper import SPARQLWrapper # type: ignore[import-untyped]
|
|
30
33
|
|
|
31
34
|
_has_sparql_wrapper = True
|
|
32
35
|
except ImportError:
|
|
33
36
|
_has_sparql_wrapper = False
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
# Pattern to match PREFIX declarations (handles URIs with # fragments)
|
|
40
|
+
_PREFIX_PATTERN = re.compile(r"PREFIX\s+\w*:\s*<[^>]*>", re.IGNORECASE)
|
|
41
|
+
# Pattern to match BASE declarations
|
|
42
|
+
_BASE_PATTERN = re.compile(r"BASE\s*<[^>]*>", re.IGNORECASE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _remove_sparql_comments(query: str) -> str:
|
|
46
|
+
"""Removes SPARQL comments while preserving # inside URIs and strings.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
query (str): The SPARQL query string.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
str: The query with comments removed.
|
|
53
|
+
"""
|
|
54
|
+
result = []
|
|
55
|
+
i = 0
|
|
56
|
+
in_uri = False
|
|
57
|
+
in_string = False
|
|
58
|
+
string_char = None
|
|
59
|
+
|
|
60
|
+
while i < len(query):
|
|
61
|
+
char = query[i]
|
|
62
|
+
|
|
63
|
+
if in_string:
|
|
64
|
+
result.append(char)
|
|
65
|
+
if char == string_char and (i == 0 or query[i - 1] != "\\"):
|
|
66
|
+
in_string = False
|
|
67
|
+
i += 1
|
|
68
|
+
elif in_uri:
|
|
69
|
+
result.append(char)
|
|
70
|
+
if char == ">":
|
|
71
|
+
in_uri = False
|
|
72
|
+
i += 1
|
|
73
|
+
elif char == "<":
|
|
74
|
+
in_uri = True
|
|
75
|
+
result.append(char)
|
|
76
|
+
i += 1
|
|
77
|
+
elif char in ('"', "'"):
|
|
78
|
+
in_string = True
|
|
79
|
+
string_char = char
|
|
80
|
+
result.append(char)
|
|
81
|
+
i += 1
|
|
82
|
+
elif char == "#":
|
|
83
|
+
# Skip until end of line
|
|
84
|
+
while i < len(query) and query[i] != "\n":
|
|
85
|
+
i += 1
|
|
86
|
+
else:
|
|
87
|
+
result.append(char)
|
|
88
|
+
i += 1
|
|
89
|
+
|
|
90
|
+
return "".join(result)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _detect_query_type(query: str) -> str:
|
|
94
|
+
"""Detects the SPARQL query type, ignoring prefixes, base, and comments.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
query (str): The SPARQL query string.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
str: The query type in uppercase (SELECT, ASK, CONSTRUCT, DESCRIBE, INSERT, DELETE, etc.)
|
|
101
|
+
or empty string if unable to determine.
|
|
102
|
+
"""
|
|
103
|
+
# Remove comments (preserving # inside URIs)
|
|
104
|
+
cleaned = _remove_sparql_comments(query)
|
|
105
|
+
# Remove all PREFIX declarations
|
|
106
|
+
cleaned = _PREFIX_PATTERN.sub("", cleaned)
|
|
107
|
+
# Remove all BASE declarations
|
|
108
|
+
cleaned = _BASE_PATTERN.sub("", cleaned)
|
|
109
|
+
# Get the first word
|
|
110
|
+
cleaned = cleaned.strip()
|
|
111
|
+
if not cleaned:
|
|
112
|
+
return ""
|
|
113
|
+
first_word = cleaned.split()[0].upper()
|
|
114
|
+
return first_word
|
|
115
|
+
|
|
116
|
+
|
|
36
117
|
class AsyncRdf4JRepository:
|
|
37
118
|
"""Asynchronous interface for interacting with an RDF4J repository."""
|
|
38
119
|
|
|
@@ -71,22 +152,57 @@ class AsyncRdf4JRepository:
|
|
|
71
152
|
self,
|
|
72
153
|
sparql_query: str,
|
|
73
154
|
infer: bool = True,
|
|
74
|
-
) -> og.QuerySolutions | og.QueryBoolean:
|
|
75
|
-
"""Executes a SPARQL SELECT
|
|
155
|
+
) -> og.QuerySolutions | og.QueryBoolean | og.QueryTriples:
|
|
156
|
+
"""Executes a SPARQL query (SELECT, ASK, CONSTRUCT, or DESCRIBE).
|
|
76
157
|
|
|
77
158
|
Args:
|
|
78
159
|
sparql_query (str): The SPARQL query string.
|
|
79
160
|
infer (bool): Whether to include inferred statements. Defaults to True.
|
|
80
161
|
|
|
81
162
|
Returns:
|
|
82
|
-
og.QuerySolutions | og.QueryBoolean: Parsed query results.
|
|
163
|
+
og.QuerySolutions | og.QueryBoolean | og.QueryTriples: Parsed query results.
|
|
164
|
+
|
|
165
|
+
Note:
|
|
166
|
+
This method correctly handles queries with PREFIX declarations,
|
|
167
|
+
BASE URIs, and comments before the query keyword.
|
|
83
168
|
"""
|
|
84
169
|
path = f"/repositories/{self._repository_id}"
|
|
85
170
|
params = {"query": sparql_query, "infer": str(infer).lower()}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
171
|
+
|
|
172
|
+
# Detect query type (handles PREFIX, BASE, comments)
|
|
173
|
+
query_type = _detect_query_type(sparql_query)
|
|
174
|
+
|
|
175
|
+
if query_type == "SELECT":
|
|
176
|
+
headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON}
|
|
177
|
+
response = await self._client.get(path, params=params, headers=headers)
|
|
178
|
+
self._handle_repo_not_found_exception(response)
|
|
179
|
+
return og.parse_query_results(
|
|
180
|
+
response.text, format=og.QueryResultsFormat.JSON
|
|
181
|
+
)
|
|
182
|
+
elif query_type == "ASK":
|
|
183
|
+
headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON}
|
|
184
|
+
response = await self._client.get(path, params=params, headers=headers)
|
|
185
|
+
self._handle_repo_not_found_exception(response)
|
|
186
|
+
return og.parse_query_results(
|
|
187
|
+
response.text, format=og.QueryResultsFormat.JSON
|
|
188
|
+
)
|
|
189
|
+
elif query_type in ("CONSTRUCT", "DESCRIBE"):
|
|
190
|
+
headers = {"Accept": Rdf4jContentType.NTRIPLES}
|
|
191
|
+
response = await self._client.get(path, params=params, headers=headers)
|
|
192
|
+
self._handle_repo_not_found_exception(response)
|
|
193
|
+
# Create temporary store to convert N-Triples response to QueryTriples
|
|
194
|
+
store = og.Store()
|
|
195
|
+
for quad in og.parse(response.text, format=og.RdfFormat.N_TRIPLES):
|
|
196
|
+
store.add(quad)
|
|
197
|
+
return store.query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")
|
|
198
|
+
else:
|
|
199
|
+
# Default to JSON for unknown query types
|
|
200
|
+
headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON}
|
|
201
|
+
response = await self._client.get(path, params=params, headers=headers)
|
|
202
|
+
self._handle_repo_not_found_exception(response)
|
|
203
|
+
return og.parse_query_results(
|
|
204
|
+
response.text, format=og.QueryResultsFormat.JSON
|
|
205
|
+
)
|
|
90
206
|
|
|
91
207
|
async def update(
|
|
92
208
|
self, sparql_update_query: str, content_type: Rdf4jContentType
|
|
@@ -111,7 +227,7 @@ class AsyncRdf4JRepository:
|
|
|
111
227
|
if response.status_code != httpx.codes.NO_CONTENT:
|
|
112
228
|
raise RepositoryUpdateException(f"Failed to update: {response.text}")
|
|
113
229
|
|
|
114
|
-
async def get_namespaces(self):
|
|
230
|
+
async def get_namespaces(self) -> list[Namespace]:
|
|
115
231
|
"""Retrieves all namespaces in the repository.
|
|
116
232
|
|
|
117
233
|
Returns:
|
|
@@ -128,7 +244,10 @@ class AsyncRdf4JRepository:
|
|
|
128
244
|
query_solutions = og.parse_query_results(
|
|
129
245
|
response.text, format=og.QueryResultsFormat.JSON
|
|
130
246
|
)
|
|
131
|
-
|
|
247
|
+
if not isinstance(query_solutions, og.QuerySolutions):
|
|
248
|
+
raise TypeError(
|
|
249
|
+
f"Expected QuerySolutions, got {type(query_solutions).__name__}"
|
|
250
|
+
)
|
|
132
251
|
return [
|
|
133
252
|
Namespace.from_sparql_query_solution(query_solution)
|
|
134
253
|
for query_solution in query_solutions
|
|
@@ -177,7 +296,7 @@ class AsyncRdf4JRepository:
|
|
|
177
296
|
|
|
178
297
|
return Namespace(prefix, response.text)
|
|
179
298
|
|
|
180
|
-
async def delete_namespace(self, prefix: str):
|
|
299
|
+
async def delete_namespace(self, prefix: str) -> None:
|
|
181
300
|
"""Deletes a namespace by prefix.
|
|
182
301
|
|
|
183
302
|
Args:
|
|
@@ -192,7 +311,7 @@ class AsyncRdf4JRepository:
|
|
|
192
311
|
self._handle_repo_not_found_exception(response)
|
|
193
312
|
response.raise_for_status()
|
|
194
313
|
|
|
195
|
-
async def clear_all_namespaces(self):
|
|
314
|
+
async def clear_all_namespaces(self) -> None:
|
|
196
315
|
"""Removes all namespaces from the repository.
|
|
197
316
|
|
|
198
317
|
Raises:
|
|
@@ -268,7 +387,7 @@ class AsyncRdf4JRepository:
|
|
|
268
387
|
predicate: Optional[Predicate] = None,
|
|
269
388
|
object_: Optional[Object] = None,
|
|
270
389
|
contexts: Optional[list[Context]] = None,
|
|
271
|
-
):
|
|
390
|
+
) -> None:
|
|
272
391
|
"""Deletes statements from the repository matching the given pattern.
|
|
273
392
|
|
|
274
393
|
Args:
|
|
@@ -308,7 +427,7 @@ class AsyncRdf4JRepository:
|
|
|
308
427
|
predicate: Predicate,
|
|
309
428
|
object: Object,
|
|
310
429
|
context: Optional[Context] = None,
|
|
311
|
-
):
|
|
430
|
+
) -> None:
|
|
312
431
|
"""Adds a single RDF statement to the repository.
|
|
313
432
|
|
|
314
433
|
Args:
|
|
@@ -337,7 +456,7 @@ class AsyncRdf4JRepository:
|
|
|
337
456
|
if response.status_code != httpx.codes.NO_CONTENT:
|
|
338
457
|
raise RepositoryUpdateException(f"Failed to add statement: {response.text}")
|
|
339
458
|
|
|
340
|
-
async def add_statements(self, statements: Iterable[Quad] | Iterable[Triple]):
|
|
459
|
+
async def add_statements(self, statements: Iterable[Quad] | Iterable[Triple]) -> None:
|
|
341
460
|
"""Adds a list of RDF statements to the repository.
|
|
342
461
|
|
|
343
462
|
Args:
|
|
@@ -364,7 +483,7 @@ class AsyncRdf4JRepository:
|
|
|
364
483
|
statements: Iterable[Quad] | Iterable[Triple],
|
|
365
484
|
contexts: Optional[Iterable[Context]] = None,
|
|
366
485
|
base_uri: Optional[str] = None,
|
|
367
|
-
):
|
|
486
|
+
) -> None:
|
|
368
487
|
"""Replaces all repository statements with the given RDF data.
|
|
369
488
|
|
|
370
489
|
Args:
|
|
@@ -396,6 +515,86 @@ class AsyncRdf4JRepository:
|
|
|
396
515
|
f"Failed to replace statements: {response.text}"
|
|
397
516
|
)
|
|
398
517
|
|
|
518
|
+
async def upload_file(
|
|
519
|
+
self,
|
|
520
|
+
file_path: Union[str, Path],
|
|
521
|
+
rdf_format: Optional[og.RdfFormat] = None,
|
|
522
|
+
context: Optional[Context] = None,
|
|
523
|
+
base_uri: Optional[str] = None,
|
|
524
|
+
) -> None:
|
|
525
|
+
"""Uploads an RDF file to the repository.
|
|
526
|
+
|
|
527
|
+
This method reads an RDF file from disk and uploads its contents to the repository.
|
|
528
|
+
The file can be in various RDF formats such as Turtle, N-Triples, N-Quads, RDF/XML, JSON-LD, TriG, or N3.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
file_path (Union[str, Path]): Path to the RDF file to upload.
|
|
532
|
+
rdf_format (Optional[og.RdfFormat]): The RDF format of the file.
|
|
533
|
+
If None, the format is automatically detected from the file extension.
|
|
534
|
+
Supported formats include:
|
|
535
|
+
- og.RdfFormat.TURTLE (.ttl)
|
|
536
|
+
- og.RdfFormat.N_TRIPLES (.nt)
|
|
537
|
+
- og.RdfFormat.N_QUADS (.nq)
|
|
538
|
+
- og.RdfFormat.RDF_XML (.rdf, .xml)
|
|
539
|
+
- og.RdfFormat.JSON_LD (.jsonld)
|
|
540
|
+
- og.RdfFormat.TRIG (.trig)
|
|
541
|
+
- og.RdfFormat.N3 (.n3)
|
|
542
|
+
context (Optional[Context]): The named graph (context) to upload statements into.
|
|
543
|
+
If None, statements are added to the default graph.
|
|
544
|
+
base_uri (Optional[str]): The base URI for resolving relative URIs in the file.
|
|
545
|
+
If None, relative URIs are resolved based on the file path.
|
|
546
|
+
|
|
547
|
+
Raises:
|
|
548
|
+
FileNotFoundError: If the specified file doesn't exist.
|
|
549
|
+
RepositoryNotFoundException: If the repository doesn't exist.
|
|
550
|
+
RepositoryUpdateException: If the upload fails.
|
|
551
|
+
ValueError: If the RDF format is not supported.
|
|
552
|
+
SyntaxError: If the file contains invalid RDF data.
|
|
553
|
+
|
|
554
|
+
Example:
|
|
555
|
+
>>> repo = await db.get_repository("my-repo")
|
|
556
|
+
>>> # Upload a Turtle file (format auto-detected)
|
|
557
|
+
>>> await repo.upload_file("data.ttl")
|
|
558
|
+
>>> # Upload to a specific named graph
|
|
559
|
+
>>> await repo.upload_file("data.ttl", context=IRI("http://example.com/graph"))
|
|
560
|
+
>>> # Upload with explicit format
|
|
561
|
+
>>> await repo.upload_file("data.txt", rdf_format=og.RdfFormat.N_TRIPLES)
|
|
562
|
+
"""
|
|
563
|
+
file_path = Path(file_path)
|
|
564
|
+
|
|
565
|
+
if not file_path.exists():
|
|
566
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
567
|
+
|
|
568
|
+
# Parse the RDF file using pyoxigraph
|
|
569
|
+
try:
|
|
570
|
+
# If base_uri is not provided, use the file path as base
|
|
571
|
+
if base_uri is None:
|
|
572
|
+
base_uri = file_path.as_uri()
|
|
573
|
+
|
|
574
|
+
# Parse the file
|
|
575
|
+
quads = list(
|
|
576
|
+
og.parse(path=str(file_path), format=rdf_format, base_iri=base_uri)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# If a context is specified, wrap all statements in that context
|
|
580
|
+
# Note: This overrides any named graph information in the file (e.g., from N-Quads)
|
|
581
|
+
if context is not None:
|
|
582
|
+
statements = [
|
|
583
|
+
Quad(q.subject, q.predicate, q.object, context) for q in quads
|
|
584
|
+
]
|
|
585
|
+
else:
|
|
586
|
+
statements = quads
|
|
587
|
+
|
|
588
|
+
# Upload the statements to the repository
|
|
589
|
+
await self.add_statements(statements)
|
|
590
|
+
|
|
591
|
+
except (ValueError, SyntaxError) as e:
|
|
592
|
+
raise type(e)(f"Failed to parse RDF file '{file_path}': {e}") from e
|
|
593
|
+
except Exception as e:
|
|
594
|
+
raise RepositoryUpdateException(
|
|
595
|
+
f"Failed to upload file '{file_path}': {e}"
|
|
596
|
+
) from e
|
|
597
|
+
|
|
399
598
|
async def get_named_graph(self, graph: str) -> AsyncNamedGraph:
|
|
400
599
|
"""Retrieves a named graph in the repository.
|
|
401
600
|
|
|
@@ -404,7 +603,39 @@ class AsyncRdf4JRepository:
|
|
|
404
603
|
"""
|
|
405
604
|
return AsyncNamedGraph(self._client, self._repository_id, graph)
|
|
406
605
|
|
|
407
|
-
def
|
|
606
|
+
def transaction(
|
|
607
|
+
self, isolation_level: Optional[IsolationLevel] = None
|
|
608
|
+
) -> AsyncTransaction:
|
|
609
|
+
"""Creates a new transaction for this repository.
|
|
610
|
+
|
|
611
|
+
Transactions allow grouping multiple operations (add, delete, update)
|
|
612
|
+
into a single atomic unit. Either all operations succeed (commit) or
|
|
613
|
+
none of them take effect (rollback).
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
isolation_level: Optional isolation level for the transaction.
|
|
617
|
+
Supported levels depend on the RDF4J store implementation.
|
|
618
|
+
Common levels include SNAPSHOT, SERIALIZABLE, READ_COMMITTED.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
AsyncTransaction: A transaction context manager.
|
|
622
|
+
|
|
623
|
+
Example:
|
|
624
|
+
```python
|
|
625
|
+
# Using as context manager (recommended)
|
|
626
|
+
async with repo.transaction() as txn:
|
|
627
|
+
await txn.add_statements([quad1, quad2])
|
|
628
|
+
await txn.delete_statements([quad3])
|
|
629
|
+
# Auto-commits on success, auto-rollbacks on exception
|
|
630
|
+
|
|
631
|
+
# With isolation level
|
|
632
|
+
async with repo.transaction(IsolationLevel.SERIALIZABLE) as txn:
|
|
633
|
+
await txn.add_statements([quad1])
|
|
634
|
+
```
|
|
635
|
+
"""
|
|
636
|
+
return AsyncTransaction(self._client, self._repository_id, isolation_level)
|
|
637
|
+
|
|
638
|
+
def _handle_repo_not_found_exception(self, response: httpx.Response) -> None:
|
|
408
639
|
"""Raises a RepositoryNotFoundException if response is 404.
|
|
409
640
|
|
|
410
641
|
Args:
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Async transaction support for RDF4J repositories."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import TYPE_CHECKING, Iterable, Optional, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from rdf4j_python.exception import TransactionError, TransactionStateError
|
|
10
|
+
from rdf4j_python.model.term import Quad, Triple
|
|
11
|
+
from rdf4j_python.utils.const import Rdf4jContentType
|
|
12
|
+
from rdf4j_python.utils.helpers import serialize_statements
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from rdf4j_python._client import AsyncApiClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TransactionState(Enum):
|
|
19
|
+
"""Represents the state of a transaction."""
|
|
20
|
+
|
|
21
|
+
PENDING = "pending" # Transaction not yet started
|
|
22
|
+
ACTIVE = "active" # Transaction is active
|
|
23
|
+
COMMITTED = "committed" # Transaction has been committed
|
|
24
|
+
ROLLED_BACK = "rolled_back" # Transaction has been rolled back
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IsolationLevel(Enum):
|
|
28
|
+
"""Transaction isolation levels supported by RDF4J.
|
|
29
|
+
|
|
30
|
+
Note: Not all RDF4J store implementations support all isolation levels.
|
|
31
|
+
If an unsupported level is requested, the store's default will be used.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
NONE = "NONE"
|
|
35
|
+
READ_UNCOMMITTED = "READ_UNCOMMITTED"
|
|
36
|
+
READ_COMMITTED = "READ_COMMITTED"
|
|
37
|
+
SNAPSHOT_READ = "SNAPSHOT_READ"
|
|
38
|
+
SNAPSHOT = "SNAPSHOT"
|
|
39
|
+
SERIALIZABLE = "SERIALIZABLE"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncTransaction:
|
|
43
|
+
"""Async context manager for transactional operations on an RDF4J repository.
|
|
44
|
+
|
|
45
|
+
Transactions allow grouping multiple operations (add, delete, update) into
|
|
46
|
+
a single atomic unit. Either all operations succeed (commit) or none of them
|
|
47
|
+
take effect (rollback).
|
|
48
|
+
|
|
49
|
+
Usage as context manager (recommended):
|
|
50
|
+
```python
|
|
51
|
+
async with repo.transaction() as txn:
|
|
52
|
+
await txn.add_statements([quad1, quad2])
|
|
53
|
+
await txn.add_statements([quad3])
|
|
54
|
+
# Auto-commits on success, auto-rollbacks on exception
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Manual usage:
|
|
58
|
+
```python
|
|
59
|
+
txn = repo.transaction()
|
|
60
|
+
await txn.begin()
|
|
61
|
+
try:
|
|
62
|
+
await txn.add_statements([quad1, quad2])
|
|
63
|
+
await txn.commit()
|
|
64
|
+
except Exception:
|
|
65
|
+
await txn.rollback()
|
|
66
|
+
raise
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
state: Current state of the transaction (PENDING, ACTIVE, COMMITTED, ROLLED_BACK)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_client: "AsyncApiClient"
|
|
74
|
+
_repository_id: str
|
|
75
|
+
_transaction_id: Optional[str]
|
|
76
|
+
_isolation_level: Optional[IsolationLevel]
|
|
77
|
+
_state: TransactionState
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
client: "AsyncApiClient",
|
|
82
|
+
repository_id: str,
|
|
83
|
+
isolation_level: Optional[IsolationLevel] = None,
|
|
84
|
+
):
|
|
85
|
+
"""Initialize a transaction.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
client: The async API client.
|
|
89
|
+
repository_id: The repository ID.
|
|
90
|
+
isolation_level: Optional isolation level for the transaction.
|
|
91
|
+
"""
|
|
92
|
+
self._client = client
|
|
93
|
+
self._repository_id = repository_id
|
|
94
|
+
self._transaction_id = None
|
|
95
|
+
self._isolation_level = isolation_level
|
|
96
|
+
self._state = TransactionState.PENDING
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def state(self) -> TransactionState:
|
|
100
|
+
"""Returns the current state of the transaction."""
|
|
101
|
+
return self._state
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_active(self) -> bool:
|
|
105
|
+
"""Returns True if the transaction is active."""
|
|
106
|
+
return self._state == TransactionState.ACTIVE
|
|
107
|
+
|
|
108
|
+
async def __aenter__(self) -> Self:
|
|
109
|
+
"""Start the transaction when entering the context."""
|
|
110
|
+
await self.begin()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(
|
|
114
|
+
self,
|
|
115
|
+
exc_type: type[BaseException] | None,
|
|
116
|
+
exc_value: BaseException | None,
|
|
117
|
+
traceback: TracebackType | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Commit or rollback the transaction when exiting the context.
|
|
120
|
+
|
|
121
|
+
If an exception occurred, the transaction is rolled back.
|
|
122
|
+
Otherwise, it is committed.
|
|
123
|
+
"""
|
|
124
|
+
if self._state != TransactionState.ACTIVE:
|
|
125
|
+
# Transaction already ended (manually committed/rolled back)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if exc_type is not None:
|
|
129
|
+
# An exception occurred, rollback
|
|
130
|
+
await self.rollback()
|
|
131
|
+
else:
|
|
132
|
+
# No exception, commit
|
|
133
|
+
await self.commit()
|
|
134
|
+
|
|
135
|
+
async def begin(self) -> None:
|
|
136
|
+
"""Start the transaction.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
TransactionStateError: If the transaction has already been started.
|
|
140
|
+
TransactionError: If the server fails to create the transaction.
|
|
141
|
+
"""
|
|
142
|
+
if self._state != TransactionState.PENDING:
|
|
143
|
+
raise TransactionStateError(
|
|
144
|
+
f"Cannot begin transaction: already in state {self._state.value}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
path = f"/repositories/{self._repository_id}/transactions"
|
|
148
|
+
params = {}
|
|
149
|
+
if self._isolation_level is not None:
|
|
150
|
+
params["isolation-level"] = self._isolation_level.value
|
|
151
|
+
|
|
152
|
+
response = await self._client.post(path, headers={}, params=params)
|
|
153
|
+
|
|
154
|
+
if response.status_code != httpx.codes.CREATED:
|
|
155
|
+
raise TransactionError(
|
|
156
|
+
f"Failed to start transaction: {response.status_code} - {response.text}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Extract transaction ID from Location header
|
|
160
|
+
location = response.headers.get("Location")
|
|
161
|
+
if not location:
|
|
162
|
+
raise TransactionError(
|
|
163
|
+
"Server did not return transaction ID in Location header"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Location is like: /rdf4j-server/repositories/{id}/transactions/{txn-id}
|
|
167
|
+
self._transaction_id = location.rstrip("/").split("/")[-1]
|
|
168
|
+
self._state = TransactionState.ACTIVE
|
|
169
|
+
|
|
170
|
+
async def commit(self) -> None:
|
|
171
|
+
"""Commit the transaction, making all changes permanent.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
TransactionStateError: If the transaction is not active.
|
|
175
|
+
TransactionError: If the commit fails.
|
|
176
|
+
"""
|
|
177
|
+
if self._state != TransactionState.ACTIVE:
|
|
178
|
+
raise TransactionStateError(
|
|
179
|
+
f"Cannot commit transaction: in state {self._state.value}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
path = f"/repositories/{self._repository_id}/transactions/{self._transaction_id}"
|
|
183
|
+
params = {"action": "COMMIT"}
|
|
184
|
+
|
|
185
|
+
response = await self._client.put(path, params=params)
|
|
186
|
+
|
|
187
|
+
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
|
|
188
|
+
raise TransactionError(
|
|
189
|
+
f"Failed to commit transaction: {response.status_code} - {response.text}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self._state = TransactionState.COMMITTED
|
|
193
|
+
|
|
194
|
+
async def rollback(self) -> None:
|
|
195
|
+
"""Rollback the transaction, discarding all changes.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
TransactionStateError: If the transaction is not active.
|
|
199
|
+
TransactionError: If the rollback fails.
|
|
200
|
+
"""
|
|
201
|
+
if self._state != TransactionState.ACTIVE:
|
|
202
|
+
raise TransactionStateError(
|
|
203
|
+
f"Cannot rollback transaction: in state {self._state.value}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
path = f"/repositories/{self._repository_id}/transactions/{self._transaction_id}"
|
|
207
|
+
|
|
208
|
+
response = await self._client.delete(path)
|
|
209
|
+
|
|
210
|
+
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
|
|
211
|
+
raise TransactionError(
|
|
212
|
+
f"Failed to rollback transaction: {response.status_code} - {response.text}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self._state = TransactionState.ROLLED_BACK
|
|
216
|
+
|
|
217
|
+
def _ensure_active(self) -> None:
|
|
218
|
+
"""Ensure the transaction is active before performing operations."""
|
|
219
|
+
if self._state != TransactionState.ACTIVE:
|
|
220
|
+
raise TransactionStateError(
|
|
221
|
+
f"Cannot perform operation: transaction in state {self._state.value}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def add_statements(
|
|
225
|
+
self, statements: Iterable[Quad] | Iterable[Triple]
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Add statements to the repository within this transaction.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
statements: The RDF statements to add.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
TransactionStateError: If the transaction is not active.
|
|
234
|
+
TransactionError: If the operation fails.
|
|
235
|
+
"""
|
|
236
|
+
self._ensure_active()
|
|
237
|
+
|
|
238
|
+
path = f"/repositories/{self._repository_id}/transactions/{self._transaction_id}"
|
|
239
|
+
params = {"action": "ADD"}
|
|
240
|
+
headers = {"Content-Type": Rdf4jContentType.NQUADS}
|
|
241
|
+
|
|
242
|
+
response = await self._client.put(
|
|
243
|
+
path,
|
|
244
|
+
content=serialize_statements(statements),
|
|
245
|
+
params=params,
|
|
246
|
+
headers=headers,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
|
|
250
|
+
raise TransactionError(
|
|
251
|
+
f"Failed to add statements: {response.status_code} - {response.text}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
async def delete_statements(
|
|
255
|
+
self, statements: Iterable[Quad] | Iterable[Triple]
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Delete specific statements from the repository within this transaction.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
statements: The RDF statements to delete.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
TransactionStateError: If the transaction is not active.
|
|
264
|
+
TransactionError: If the operation fails.
|
|
265
|
+
"""
|
|
266
|
+
self._ensure_active()
|
|
267
|
+
|
|
268
|
+
path = f"/repositories/{self._repository_id}/transactions/{self._transaction_id}"
|
|
269
|
+
params = {"action": "DELETE"}
|
|
270
|
+
headers = {"Content-Type": Rdf4jContentType.NQUADS}
|
|
271
|
+
|
|
272
|
+
response = await self._client.put(
|
|
273
|
+
path,
|
|
274
|
+
content=serialize_statements(statements),
|
|
275
|
+
params=params,
|
|
276
|
+
headers=headers,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
|
|
280
|
+
raise TransactionError(
|
|
281
|
+
f"Failed to delete statements: {response.status_code} - {response.text}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def update(self, sparql_update: str) -> None:
|
|
285
|
+
"""Execute a SPARQL UPDATE within this transaction.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
sparql_update: The SPARQL UPDATE query string.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
TransactionStateError: If the transaction is not active.
|
|
292
|
+
TransactionError: If the operation fails.
|
|
293
|
+
"""
|
|
294
|
+
self._ensure_active()
|
|
295
|
+
|
|
296
|
+
path = f"/repositories/{self._repository_id}/transactions/{self._transaction_id}"
|
|
297
|
+
params = {"action": "UPDATE"}
|
|
298
|
+
headers = {"Content-Type": Rdf4jContentType.SPARQL_UPDATE}
|
|
299
|
+
|
|
300
|
+
response = await self._client.put(
|
|
301
|
+
path,
|
|
302
|
+
content=sparql_update,
|
|
303
|
+
params=params,
|
|
304
|
+
headers=headers,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if response.status_code not in (httpx.codes.OK, httpx.codes.NO_CONTENT):
|
|
308
|
+
raise TransactionError(
|
|
309
|
+
f"Failed to execute update: {response.status_code} - {response.text}"
|
|
310
|
+
)
|