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.
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/.coverage +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/PKG-INFO +1 -1
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/config.example.toml +11 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/pyproject.toml +6 -6
- sparql_cli-0.1.4/sparql/_version.py +1 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/query.py +1 -0
- sparql_cli-0.1.4/sparql/cli/commands/update.py +171 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/main.py +2 -0
- sparql_cli-0.1.4/sparql/core/client.py +227 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/config.py +52 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/models.py +11 -2
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/query_source.py +21 -2
- sparql_cli-0.1.4/tests/cli/test_update.py +123 -0
- sparql_cli-0.1.4/tests/core/test_client.py +444 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_config.py +207 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_models.py +32 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_query_source.py +45 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/uv.lock +1 -1
- sparql_cli-0.1.2/sparql/_version.py +0 -1
- sparql_cli-0.1.2/sparql/core/client.py +0 -161
- sparql_cli-0.1.2/tests/core/test_client.py +0 -196
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/LICENSE +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/README.md +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/__main__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/config.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/commands/convenience.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/cli/output.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/exceptions.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/exit_codes.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/logging.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/monitoring.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/core/prefixes.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/base.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/csv.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/json.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/sparql/formatters/table.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_config_command.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_convenience_commands.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_output.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/cli/test_query_command.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/conftest.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/core/test_prefixes.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/__init__.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_csv_formatter.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_json_formatter.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_registry.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/formatters/test_table_formatter.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_exceptions.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_logging.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_monitoring.py +0 -0
- {sparql_cli-0.1.2 → sparql_cli-0.1.4}/tests/test_package.py +0 -0
|
Binary file
|
|
@@ -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.
|
|
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
|
-
[
|
|
41
|
-
|
|
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"
|
|
@@ -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
|
-
|
|
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)
|