sparql-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (59) hide show
  1. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/.coverage +0 -0
  2. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/PKG-INFO +1 -1
  3. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/config.example.toml +11 -0
  4. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/pyproject.toml +6 -6
  5. sparql_cli-0.1.4/sparql/_version.py +1 -0
  6. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/query.py +1 -0
  7. sparql_cli-0.1.4/sparql/cli/commands/update.py +171 -0
  8. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/main.py +2 -0
  9. sparql_cli-0.1.4/sparql/core/client.py +227 -0
  10. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/config.py +52 -0
  11. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/models.py +11 -2
  12. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/query_source.py +21 -2
  13. sparql_cli-0.1.4/tests/cli/test_update.py +123 -0
  14. sparql_cli-0.1.4/tests/core/test_client.py +444 -0
  15. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_config.py +207 -0
  16. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_models.py +32 -0
  17. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_query_source.py +45 -0
  18. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/uv.lock +1 -1
  19. sparql_cli-0.1.2/sparql/_version.py +0 -1
  20. sparql_cli-0.1.2/sparql/core/client.py +0 -161
  21. sparql_cli-0.1.2/tests/core/test_client.py +0 -196
  22. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/LICENSE +0 -0
  23. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/README.md +0 -0
  24. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/__init__.py +0 -0
  25. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/__main__.py +0 -0
  26. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/__init__.py +0 -0
  27. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/__init__.py +0 -0
  28. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/config.py +0 -0
  29. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/convenience.py +0 -0
  30. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/output.py +0 -0
  31. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/__init__.py +0 -0
  32. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/exceptions.py +0 -0
  33. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/exit_codes.py +0 -0
  34. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/logging.py +0 -0
  35. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/monitoring.py +0 -0
  36. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/prefixes.py +0 -0
  37. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/__init__.py +0 -0
  38. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/base.py +0 -0
  39. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/csv.py +0 -0
  40. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/json.py +0 -0
  41. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/table.py +0 -0
  42. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/__init__.py +0 -0
  43. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/__init__.py +0 -0
  44. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_config_command.py +0 -0
  45. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_convenience_commands.py +0 -0
  46. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_output.py +0 -0
  47. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_query_command.py +0 -0
  48. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/conftest.py +0 -0
  49. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/__init__.py +0 -0
  50. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_prefixes.py +0 -0
  51. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/__init__.py +0 -0
  52. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_csv_formatter.py +0 -0
  53. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_json_formatter.py +0 -0
  54. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_registry.py +0 -0
  55. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_table_formatter.py +0 -0
  56. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_exceptions.py +0 -0
  57. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_logging.py +0 -0
  58. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_monitoring.py +0 -0
  59. {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sparql-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: CLI tool for querying SPARQL endpoints
5
5
  Project-URL: Homepage, https://github.com/vladistan/sparql-cli
6
6
  Project-URL: Repository, https://github.com/vladistan/sparql-cli
@@ -13,6 +13,17 @@ url = "https://dbpedia.org/sparql"
13
13
  endpoint_type = "virtuoso"
14
14
  timeout = 30.0
15
15
 
16
+ # --- Writable Endpoint Example ---
17
+ # For endpoints that support SPARQL UPDATE (INSERT, DELETE, LOAD, etc.),
18
+ # set update_url to the update endpoint. If omitted, sparql-cli derives it:
19
+ # 1. Explicit update_url (if set)
20
+ # 2. Replace /query with /update in the endpoint URL
21
+ # 3. Use the endpoint URL as-is (fallback)
22
+ [endpoints.fuseki-local]
23
+ url = "http://localhost:3030/dataset/query"
24
+ # update_url = "http://localhost:3030/dataset/update" # auto-derived from url
25
+ timeout = 30.0
26
+
16
27
  [endpoints.uniprot]
17
28
  url = "https://sparql.uniprot.org/sparql"
18
29
  endpoint_type = "generic"
@@ -2,12 +2,9 @@
2
2
  requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
- [tool.hatch.build.targets.wheel]
6
- packages = ["sparql"]
7
-
8
5
  [project]
9
6
  name = "sparql-cli"
10
- version = "0.1.2"
7
+ version = "0.1.4"
11
8
  description = "CLI tool for querying SPARQL endpoints"
12
9
  readme = "README.md"
13
10
  authors = [
@@ -32,13 +29,16 @@ dev = [
32
29
  "ruff>=0.1.0"
33
30
  ]
34
31
 
32
+ [project.scripts]
33
+ sparql = "sparql.cli.main:app"
34
+
35
35
  [project.urls]
36
36
  Homepage = "https://github.com/vladistan/sparql-cli"
37
37
  Repository = "https://github.com/vladistan/sparql-cli"
38
38
  Issues = "https://github.com/vladistan/sparql-cli/issues"
39
39
 
40
- [project.scripts]
41
- sparql = "sparql.cli.main:app"
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["sparql"]
42
42
 
43
43
  [tool.mypy]
44
44
  python_version = "3.13"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -186,6 +186,7 @@ def query(
186
186
  username=resolved.username,
187
187
  password=resolved.password,
188
188
  digest_auth=resolved.auth_type == AuthType.DIGEST,
189
+ http_method=resolved.http_method.value,
189
190
  )
190
191
 
191
192
  # Resolve output format with precedence:
@@ -0,0 +1,171 @@
1
+ """Update command for executing SPARQL UPDATE operations against endpoints."""
2
+
3
+ import sys
4
+
5
+ import sentry_sdk
6
+ import typer
7
+
8
+ from sparql._version import __version__
9
+ from sparql.core.client import SPARQLClient
10
+ from sparql.core.config import AuthType, load_config, resolve_config
11
+ from sparql.core.exceptions import ConfigError, NetworkError
12
+ from sparql.core.exceptions import TimeoutError as SPARQLTimeoutError
13
+ from sparql.core.exit_codes import ExitCode
14
+ from sparql.core.logging import get_logger
15
+ from sparql.core.query_source import resolve_query_source
16
+
17
+
18
+ def _get_global_options(
19
+ ctx: typer.Context | None,
20
+ ) -> tuple[str | None, str | None]:
21
+ if ctx and ctx.obj:
22
+ return (
23
+ ctx.obj.get("profile"),
24
+ ctx.obj.get("endpoint"),
25
+ )
26
+ return None, None
27
+
28
+
29
+ def update(
30
+ ctx: typer.Context,
31
+ update_file: str | None = typer.Argument( # noqa: B008
32
+ None,
33
+ help="SPARQL update file (.ru) or inline update string",
34
+ ),
35
+ endpoint: str | None = typer.Option( # noqa: B008
36
+ None,
37
+ "--endpoint",
38
+ "-E",
39
+ help="SPARQL endpoint URL (overrides config)",
40
+ ),
41
+ profile: str | None = typer.Option( # noqa: B008
42
+ None,
43
+ "--profile",
44
+ "-P",
45
+ help="Use named endpoint profile from config",
46
+ ),
47
+ execute: str | None = typer.Option( # noqa: B008
48
+ None,
49
+ "--execute",
50
+ "-e",
51
+ help="Execute inline SPARQL update",
52
+ ),
53
+ timeout: float | None = typer.Option( # noqa: B008
54
+ None,
55
+ "--timeout",
56
+ "-t",
57
+ help="Update timeout in seconds (overrides config)",
58
+ ),
59
+ user: str | None = typer.Option( # noqa: B008
60
+ None,
61
+ "--user",
62
+ "-u",
63
+ help="Username for authentication (overrides config)",
64
+ ),
65
+ password: str | None = typer.Option( # noqa: B008
66
+ None,
67
+ "--password",
68
+ "-p",
69
+ help="Password for authentication (overrides config)",
70
+ ),
71
+ digest_auth: bool = typer.Option( # noqa: B008
72
+ False,
73
+ "--digest",
74
+ help="Use HTTP Digest Authentication instead of Basic",
75
+ ),
76
+ verbose: bool = typer.Option( # noqa: B008
77
+ False,
78
+ "--verbose",
79
+ help="Show endpoint and update before execution",
80
+ ),
81
+ ) -> None:
82
+ """Execute a SPARQL UPDATE operation against an endpoint.
83
+
84
+ Update can be provided via file, -e inline, or stdin.
85
+ Sends POST with Content-Type: application/sparql-update.
86
+ """
87
+ stdin = None
88
+ if not sys.stdin.isatty():
89
+ stdin = sys.stdin
90
+
91
+ try:
92
+ update_text = resolve_query_source(
93
+ inline=execute,
94
+ file_path=update_file,
95
+ stdin=stdin,
96
+ )
97
+ except ConfigError as e:
98
+ typer.echo(str(e), err=True)
99
+ raise typer.Exit(ExitCode.INPUT_ERROR) from e
100
+
101
+ # Merge global options (command-specific takes precedence)
102
+ global_profile, global_endpoint = _get_global_options(ctx)
103
+ profile = profile or global_profile
104
+ endpoint = endpoint or global_endpoint
105
+
106
+ # Resolve configuration with precedence
107
+ try:
108
+ config = load_config()
109
+ cli_auth_type = AuthType.DIGEST if digest_auth else None
110
+ resolved = resolve_config(
111
+ config,
112
+ profile=profile,
113
+ cli_endpoint=endpoint,
114
+ cli_timeout=timeout,
115
+ cli_username=user,
116
+ cli_password=password,
117
+ cli_auth_type=cli_auth_type,
118
+ )
119
+ except ConfigError as e:
120
+ typer.echo(f"Config error: {e}", err=True)
121
+ raise typer.Exit(ExitCode.CONFIG_ERROR) from e
122
+
123
+ logger = get_logger("update")
124
+
125
+ if verbose:
126
+ typer.echo(f"Update endpoint: {resolved.update_endpoint}", err=True)
127
+ typer.echo(f"Timeout: {resolved.timeout}s", err=True)
128
+ typer.echo("Update:", err=True)
129
+ typer.echo(update_text, err=True)
130
+ typer.echo("---", err=True)
131
+
132
+ client = SPARQLClient(
133
+ endpoint_url=resolved.endpoint,
134
+ timeout=resolved.timeout,
135
+ user_agent=resolved.user_agent or f"sparql-cli/{__version__}",
136
+ username=resolved.username,
137
+ password=resolved.password,
138
+ digest_auth=resolved.auth_type == AuthType.DIGEST,
139
+ http_method=resolved.http_method.value,
140
+ )
141
+
142
+ logger.debug(
143
+ "update.execute",
144
+ endpoint=resolved.update_endpoint,
145
+ update_bytes=len(update_text),
146
+ )
147
+
148
+ try:
149
+ with sentry_sdk.start_span(
150
+ op="sparql.update", name="SPARQL UPDATE"
151
+ ):
152
+ result = client.execute_update(
153
+ update_text, resolved.update_endpoint
154
+ )
155
+
156
+ if result.success:
157
+ typer.echo(f"Update successful (HTTP {result.status_code})")
158
+ logger.debug("update.complete", status_code=result.status_code)
159
+ else:
160
+ typer.echo(
161
+ f"Update failed (HTTP {result.status_code}): "
162
+ f"{result.message}",
163
+ err=True,
164
+ )
165
+ raise typer.Exit(ExitCode.NETWORK_ERROR)
166
+ except SPARQLTimeoutError as e:
167
+ typer.echo(f"Timeout: {e}", err=True)
168
+ raise typer.Exit(ExitCode.TIMEOUT) from e
169
+ except NetworkError as e:
170
+ typer.echo(f"Error: {e}", err=True)
171
+ raise typer.Exit(ExitCode.NETWORK_ERROR) from e
@@ -17,6 +17,7 @@ from sparql.cli.commands.convenience import (
17
17
  predicates,
18
18
  )
19
19
  from sparql.cli.commands.query import query
20
+ from sparql.cli.commands.update import update
20
21
  from sparql.core.logging import setup_logging
21
22
  from sparql.core.monitoring import setup_sentry
22
23
 
@@ -152,6 +153,7 @@ def test_sentry() -> None:
152
153
 
153
154
  # Register commands
154
155
  app.command()(query)
156
+ app.command()(update)
155
157
  app.command()(graphs)
156
158
  app.command()(classes)
157
159
  app.command()(predicates)
@@ -0,0 +1,227 @@
1
+ """SPARQL endpoint client using httpx."""
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+
6
+ import httpx
7
+ import sentry_sdk
8
+
9
+ from sparql.core.exceptions import NetworkError
10
+ from sparql.core.exceptions import TimeoutError as SPARQLTimeoutError
11
+ from sparql.core.logging import get_logger
12
+ from sparql.core.models import BindingValue, QueryResult, UpdateResult
13
+
14
+
15
+ def _is_rdf_query(query: str) -> bool:
16
+ """Detect if query is CONSTRUCT or DESCRIBE (returns RDF graph)."""
17
+ query_upper = query.strip().upper()
18
+ # Match CONSTRUCT or DESCRIBE at start (after optional PREFIX declarations)
19
+ return bool(re.search(r"\b(CONSTRUCT|DESCRIBE)\b", query_upper))
20
+
21
+
22
+ class SPARQLClient:
23
+ """Executes SPARQL queries against remote endpoints.
24
+
25
+ Supports both GET and POST methods. GET is required for some endpoints
26
+ (e.g., Virtuoso), while POST is standard for most SPARQL endpoints.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ endpoint_url: str,
32
+ timeout: float,
33
+ user_agent: str,
34
+ username: str | None = None,
35
+ password: str | None = None,
36
+ digest_auth: bool = False,
37
+ http_method: str = "POST",
38
+ ) -> None:
39
+ self.endpoint_url = endpoint_url
40
+ self.timeout = timeout
41
+ self.user_agent = user_agent
42
+ self.http_method = http_method
43
+ self._logger = get_logger("client")
44
+ self.auth: httpx.DigestAuth | tuple[str, str] | None = None
45
+ if username and password:
46
+ if digest_auth:
47
+ self.auth = httpx.DigestAuth(username, password)
48
+ else:
49
+ self.auth = (username, password)
50
+
51
+ def _http_error_message(self, e: httpx.HTTPStatusError) -> str:
52
+ """Build diagnostic context from HTTP status, redirects, and response body."""
53
+ msg = f"HTTP {e.response.status_code} from {self.endpoint_url}"
54
+ if e.response.status_code == 302:
55
+ location = e.response.headers.get("Location", "")
56
+ if location:
57
+ msg += f"\nRedirect to: {location[:200]}"
58
+ msg += "\n(Endpoint redirected - may not support this query)"
59
+ elif e.response.status_code in (400, 500):
60
+ body = e.response.text[:500] if e.response.text else ""
61
+ if body:
62
+ msg += f"\nResponse: {body}"
63
+ return msg
64
+
65
+ def execute(self, query: str) -> Iterator[QueryResult]:
66
+ """Execute SELECT/ASK query returning tabular results.
67
+
68
+ Yields QueryResult objects with bindings. First result includes
69
+ variable ordering from head.vars. Subsequent results omit variables.
70
+ """
71
+ self._logger.debug(
72
+ "query.execute",
73
+ endpoint=self.endpoint_url,
74
+ query_bytes=len(query),
75
+ http_method=self.http_method,
76
+ )
77
+ try:
78
+ with sentry_sdk.start_span(op="http.client", name="SPARQL SELECT/ASK"):
79
+ with httpx.Client(timeout=self.timeout, auth=self.auth) as client:
80
+ headers = {
81
+ "Accept": "application/sparql-results+json",
82
+ "User-Agent": self.user_agent,
83
+ }
84
+
85
+ if self.http_method == "GET":
86
+ response = client.get(
87
+ self.endpoint_url,
88
+ params={"query": query},
89
+ headers=headers,
90
+ )
91
+ else:
92
+ response = client.post(
93
+ self.endpoint_url,
94
+ data={"query": query},
95
+ headers=headers,
96
+ )
97
+ response.raise_for_status()
98
+
99
+ data = response.json()
100
+
101
+ # ASK queries return {"head": {}, "boolean": true/false}
102
+ # per SPARQL 1.1 Results JSON spec — no "results" key.
103
+ if "boolean" in data:
104
+ value = "true" if data["boolean"] else "false"
105
+ bv = BindingValue(type="literal", value=value)
106
+ yield QueryResult(
107
+ bindings={"boolean": bv},
108
+ variables=["boolean"],
109
+ )
110
+ return
111
+
112
+ variables = data.get("head", {}).get("vars", [])
113
+
114
+ for idx, result_row in enumerate(data["results"]["bindings"]):
115
+ bindings = {
116
+ var: BindingValue(**value_dict)
117
+ for var, value_dict in result_row.items()
118
+ }
119
+ # Include variable order only in first result
120
+ if idx == 0:
121
+ yield QueryResult(bindings=bindings, variables=variables)
122
+ else:
123
+ yield QueryResult(bindings=bindings)
124
+
125
+ except httpx.TimeoutException as e:
126
+ raise SPARQLTimeoutError(
127
+ f"Query timed out after {self.timeout}s: {self.endpoint_url}"
128
+ ) from e
129
+ except httpx.HTTPStatusError as e:
130
+ raise NetworkError(self._http_error_message(e)) from e
131
+ except httpx.RequestError as e:
132
+ raise NetworkError(f"Failed to connect to {self.endpoint_url}: {e}") from e
133
+
134
+ def execute_update(
135
+ self, update: str, update_endpoint: str
136
+ ) -> UpdateResult:
137
+ """Execute a SPARQL UPDATE operation (INSERT, DELETE, LOAD, etc.).
138
+
139
+ Uses POST with Content-Type: application/sparql-update.
140
+ Returns UpdateResult instead of raising on HTTP 4xx errors,
141
+ since update failures are expected operational outcomes.
142
+ """
143
+ self._logger.debug(
144
+ "update.execute",
145
+ endpoint=update_endpoint,
146
+ update_bytes=len(update),
147
+ )
148
+ try:
149
+ with sentry_sdk.start_span(
150
+ op="http.client", name="SPARQL UPDATE"
151
+ ):
152
+ with httpx.Client(
153
+ timeout=self.timeout, auth=self.auth
154
+ ) as client:
155
+ headers = {
156
+ "Content-Type": "application/sparql-update",
157
+ "User-Agent": self.user_agent,
158
+ }
159
+ response = client.post(
160
+ update_endpoint,
161
+ content=update,
162
+ headers=headers,
163
+ )
164
+ response.raise_for_status()
165
+ return UpdateResult(
166
+ success=True,
167
+ status_code=response.status_code,
168
+ message=response.text,
169
+ )
170
+ except httpx.HTTPStatusError as e:
171
+ return UpdateResult(
172
+ success=False,
173
+ status_code=e.response.status_code,
174
+ message=e.response.text[:500],
175
+ )
176
+ except httpx.TimeoutException as e:
177
+ raise SPARQLTimeoutError(
178
+ f"Update timed out after {self.timeout}s: "
179
+ f"{update_endpoint}"
180
+ ) from e
181
+ except httpx.RequestError as e:
182
+ raise NetworkError(
183
+ f"Failed to connect to {update_endpoint}: {e}"
184
+ ) from e
185
+
186
+ def execute_rdf(self, query: str, accept_header: str) -> str:
187
+ """Execute CONSTRUCT/DESCRIBE query returning server-serialized RDF graph."""
188
+ self._logger.debug(
189
+ "query.execute_rdf",
190
+ endpoint=self.endpoint_url,
191
+ accept=accept_header,
192
+ query_bytes=len(query),
193
+ http_method=self.http_method,
194
+ )
195
+ try:
196
+ with sentry_sdk.start_span(
197
+ op="http.client", name="SPARQL CONSTRUCT/DESCRIBE"
198
+ ):
199
+ with httpx.Client(timeout=self.timeout, auth=self.auth) as client:
200
+ headers = {
201
+ "Accept": accept_header,
202
+ "User-Agent": self.user_agent,
203
+ }
204
+
205
+ if self.http_method == "GET":
206
+ response = client.get(
207
+ self.endpoint_url,
208
+ params={"query": query},
209
+ headers=headers,
210
+ )
211
+ else:
212
+ response = client.post(
213
+ self.endpoint_url,
214
+ data={"query": query},
215
+ headers=headers,
216
+ )
217
+ response.raise_for_status()
218
+ return response.text
219
+
220
+ except httpx.TimeoutException as e:
221
+ raise SPARQLTimeoutError(
222
+ f"Query timed out after {self.timeout}s: {self.endpoint_url}"
223
+ ) from e
224
+ except httpx.HTTPStatusError as e:
225
+ raise NetworkError(self._http_error_message(e)) from e
226
+ except httpx.RequestError as e:
227
+ raise NetworkError(f"Failed to connect to {self.endpoint_url}: {e}") from e
@@ -31,6 +31,13 @@ class EndpointType(str, Enum):
31
31
  GRAPHDB = "graphdb"
32
32
 
33
33
 
34
+ class HTTPMethod(str, Enum):
35
+ """HTTP methods for SPARQL queries."""
36
+
37
+ GET = "GET"
38
+ POST = "POST"
39
+
40
+
34
41
  class EndpointProfile(BaseModel):
35
42
  """Named endpoint configuration with optional authentication."""
36
43
 
@@ -41,6 +48,8 @@ class EndpointProfile(BaseModel):
41
48
  username: str | None = None
42
49
  password: str | None = None
43
50
  auth_type: AuthType = AuthType.NONE
51
+ http_method: HTTPMethod | None = None
52
+ update_url: str | None = None
44
53
 
45
54
  # Server-specific parameters
46
55
  database: str | None = None # MarkLogic database name
@@ -150,6 +159,9 @@ class ResolvedConfig(BaseModel):
150
159
  password: str | None = None
151
160
  auth_type: AuthType = AuthType.NONE
152
161
  user_agent: str | None = None
162
+ http_method: HTTPMethod = HTTPMethod.POST
163
+
164
+ update_endpoint: str = ""
153
165
 
154
166
  # Server-specific parameters
155
167
  database: str | None = None
@@ -158,6 +170,32 @@ class ResolvedConfig(BaseModel):
158
170
  reasoning: bool | None = None
159
171
 
160
172
 
173
+ def _derive_update_url(endpoint_url: str, explicit_update_url: str | None) -> str:
174
+ """Derive SPARQL UPDATE endpoint URL.
175
+
176
+ Priority: explicit update_url > replace /query path segment > endpoint as-is.
177
+ Only replaces /query in the URL path, not in the hostname.
178
+ """
179
+ if explicit_update_url:
180
+ return explicit_update_url
181
+
182
+ # Split at :// to isolate scheme from rest, then find path
183
+ scheme_sep = endpoint_url.find("://")
184
+ if scheme_sep == -1:
185
+ return endpoint_url
186
+ after_scheme = endpoint_url[scheme_sep + 3 :]
187
+ slash_pos = after_scheme.find("/")
188
+ if slash_pos == -1:
189
+ return endpoint_url
190
+
191
+ host = endpoint_url[: scheme_sep + 3 + slash_pos]
192
+ path = after_scheme[slash_pos:]
193
+
194
+ if "/query" in path:
195
+ return host + path.replace("/query", "/update", 1)
196
+ return endpoint_url
197
+
198
+
161
199
  def resolve_config(
162
200
  config: AppConfig,
163
201
  *,
@@ -228,6 +266,18 @@ def resolve_config(
228
266
  # Resolve format: CLI > Config default
229
267
  format_value = cli_format or config.default_format
230
268
 
269
+ # Resolve HTTP method: Profile > Auto-detect from endpoint type
270
+ # VIRTUOSO endpoints require GET, all others default to POST
271
+ if profile_config.http_method is not None:
272
+ http_method = profile_config.http_method
273
+ elif profile_config.endpoint_type == EndpointType.VIRTUOSO:
274
+ http_method = HTTPMethod.GET
275
+ else:
276
+ http_method = HTTPMethod.POST
277
+
278
+ # Derive update endpoint URL
279
+ update_endpoint = _derive_update_url(endpoint, profile_config.update_url)
280
+
231
281
  return ResolvedConfig(
232
282
  endpoint=endpoint,
233
283
  endpoint_type=profile_config.endpoint_type,
@@ -237,6 +287,8 @@ def resolve_config(
237
287
  password=password,
238
288
  auth_type=auth_type,
239
289
  user_agent=profile_config.user_agent,
290
+ http_method=http_method,
291
+ update_endpoint=update_endpoint,
240
292
  database=profile_config.database,
241
293
  namespace=profile_config.namespace,
242
294
  repository=profile_config.repository,
@@ -55,8 +55,17 @@ class QueryResult(BaseModel):
55
55
 
56
56
 
57
57
  class EndpointConfig(BaseModel):
58
- """Configuration for a SPARQL endpoint connection."""
59
-
60
58
  url: HttpUrl
61
59
  timeout: float = Field(default=30.0, gt=0)
62
60
  user_agent: str = "sparql-cli/1.0"
61
+
62
+
63
+ class UpdateResult(BaseModel):
64
+ """Captures the outcome of INSERT, DELETE, LOAD, CLEAR, DROP operations.
65
+
66
+ Unlike queries, updates don't return bindings - only success/failure status.
67
+ """
68
+
69
+ success: bool
70
+ status_code: int
71
+ message: str
@@ -11,6 +11,26 @@ from typing import TextIO
11
11
 
12
12
  from sparql.core.exceptions import ConfigError
13
13
 
14
+ _QUERY_KEYWORDS = ("SELECT", "ASK", "CONSTRUCT", "DESCRIBE", "PREFIX")
15
+ _UPDATE_KEYWORDS = (
16
+ "INSERT", "DELETE", "LOAD", "CLEAR", "DROP", "CREATE", "COPY", "MOVE", "ADD",
17
+ )
18
+ _ALL_SPARQL_KEYWORDS = _QUERY_KEYWORDS + _UPDATE_KEYWORDS
19
+
20
+
21
+ def is_update_query(query: str) -> bool:
22
+ """Check whether a SPARQL string is an UPDATE operation.
23
+
24
+ Handles PREFIX declarations before the actual UPDATE keyword.
25
+ """
26
+ import re
27
+
28
+ # Strip PREFIX declarations (single or multi-line)
29
+ text = re.sub(
30
+ r"(?i)\bPREFIX\s+\S+\s+<[^>]*>\s*", "", query.strip()
31
+ ).strip().upper()
32
+ return text.startswith(_UPDATE_KEYWORDS)
33
+
14
34
 
15
35
  def resolve_query_source(
16
36
  inline: str | None,
@@ -33,8 +53,7 @@ def resolve_query_source(
33
53
  # Convert to string first to check for inline SPARQL
34
54
  # (must happen before Path conversion to preserve // in URLs)
35
55
  path_str = str(file_path)
36
- sparql_keywords = ("SELECT", "ASK", "CONSTRUCT", "DESCRIBE", "PREFIX")
37
- if path_str.upper().startswith(sparql_keywords):
56
+ if path_str.upper().startswith(_ALL_SPARQL_KEYWORDS):
38
57
  return path_str.strip()
39
58
  # It's a file path
40
59
  path = file_path if isinstance(file_path, Path) else Path(file_path)