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.
@@ -1,10 +1,13 @@
1
- from typing import Iterable, Optional
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 query.
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
- headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON}
87
- response = await self._client.get(path, params=params, headers=headers)
88
- self._handle_repo_not_found_exception(response)
89
- return og.parse_query_results(response.text, format=og.QueryResultsFormat.JSON)
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
- assert isinstance(query_solutions, og.QuerySolutions)
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 _handle_repo_not_found_exception(self, response: httpx.Response):
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
+ )