Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ env:
POETRY_CACHE_DIR: ${{ github.workspace }}/.var/cache/pypoetry
PIP_CACHE_DIR: ${{ github.workspace }}/.var/cache/pip


concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -52,7 +51,7 @@ jobs:
PREPARATION: "sudo apt-get install -y firejail"
extensive-tests: true
TOX_TEST_HARNESS: "firejail --net=none --"
TOX_PYTEST_EXTRA_ARGS: "-m 'not webtest'"
TOX_PYTEST_EXTRA_ARGS: "-m 'not (testcontainer or webtest)'"
steps:
- uses: actions/checkout@v4
- name: Cache XDG_CACHE_HOME
Expand Down Expand Up @@ -84,6 +83,13 @@ jobs:
shell: bash
run: |
${{ matrix.PREPARATION }}
- name: Set testcontainer exclusion for non-Linux
if: ${{ matrix.os != 'ubuntu-latest' }}
shell: bash
run: |
if [ -z "${{ matrix.TOX_PYTEST_EXTRA_ARGS }}" ]; then
echo "TOX_PYTEST_EXTRA_ARGS=-m 'not testcontainer'" >> $GITHUB_ENV
fi
- name: Run validation
shell: bash
run: |
Expand All @@ -97,7 +103,7 @@ jobs:
gha:validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TOX_PYTEST_EXTRA_ARGS: ${{ matrix.TOX_PYTEST_EXTRA_ARGS }}
TOX_PYTEST_EXTRA_ARGS: ${{ matrix.TOX_PYTEST_EXTRA_ARGS || env.TOX_PYTEST_EXTRA_ARGS }}
TOX_TEST_HARNESS: ${{ matrix.TOX_TEST_HARNESS }}
TOX_EXTRA_COMMAND: ${{ matrix.TOX_EXTRA_COMMAND }}
- uses: actions/upload-artifact@v4
Expand Down
1,026 changes: 489 additions & 537 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ rdfs2dot = 'rdflib.tools.rdfs2dot:main'
rdfgraphisomorphism = 'rdflib.tools.graphisomorphism:main'

[tool.poetry.dependencies]
# TODO: temporarily add new python version constraints for testcontainers
# We can remove the upper bound once testcontainers releases a new version
# https://github.com/testcontainers/testcontainers-python/pull/909
python = ">=3.9.2, <4.0"
python = ">=3.8.1"
isodate = {version=">=0.7.2,<1.0.0", python = "<3.11"}
pyparsing = ">=2.1.0,<4"
berkeleydb = {version = "^18.1.0", optional = true}
Expand All @@ -67,7 +64,7 @@ coverage = {version = "^7.0.1", extras = ["toml"]}
types-setuptools = ">=68.0.0.3,<72.0.0.0"
setuptools = ">=68,<72"
wheel = ">=0.42,<0.46"
testcontainers = "^4.13.2"
testcontainers = {version = "^4.13.2", python = ">=3.9.2"}

[tool.poetry.group.docs.dependencies]
typing-extensions = "^4.11.0"
Expand Down
55 changes: 40 additions & 15 deletions rdflib/contrib/rdf4j/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@
build_sparql_query_accept_header,
build_spo_param,
rdf_payload_to_stream,
validate_graph_name,
validate_no_bnodes,
)
from rdflib.graph import DATASET_DEFAULT_GRAPH_ID, Dataset, Graph
from rdflib.query import Result
from rdflib.term import IdentifiedNode, Literal, URIRef

SubjectType = t.Union[IdentifiedNode, None]
SubjectType = t.Union[URIRef, None]
PredicateType = t.Union[URIRef, None]
ObjectType = t.Union[IdentifiedNode, Literal, None]
ObjectType = t.Union[URIRef, Literal, None]


@dataclass(frozen=True)
Expand Down Expand Up @@ -198,8 +200,14 @@ def identifier(self):
@staticmethod
def _build_graph_name_params(graph_name: URIRef | str):
params = {}
if isinstance(graph_name, URIRef) and graph_name == DATASET_DEFAULT_GRAPH_ID:
# Do nothing; GraphDB does not work with `?default=`, which is the default
if (
isinstance(graph_name, URIRef)
and graph_name == DATASET_DEFAULT_GRAPH_ID
or isinstance(graph_name, str)
and graph_name == str(DATASET_DEFAULT_GRAPH_ID)
):
# Do nothing; GraphDB does not work with `?default=`
# (note the trailing equal character), which is the default
# behavior of httpx when setting the param value to an empty string.
# httpx completely omits query parameters whose values are `None`, so that's
# not an option either.
Expand Down Expand Up @@ -231,6 +239,7 @@ def get(self, graph_name: URIRef | str) -> Graph:
"""
if not graph_name:
raise ValueError("Graph name must be provided.")
validate_graph_name(graph_name)
headers = {
"Accept": self._content_type,
}
Expand Down Expand Up @@ -260,6 +269,7 @@ def add(self, graph_name: URIRef | str, data: str | bytes | BinaryIO | Graph):
"""
if not graph_name:
raise ValueError("Graph name must be provided.")
validate_graph_name(graph_name)
stream, should_close = rdf_payload_to_stream(data)
headers = {
"Content-Type": self._content_type,
Expand Down Expand Up @@ -290,6 +300,7 @@ def overwrite(self, graph_name: URIRef | str, data: str | bytes | BinaryIO | Gra
"""
if not graph_name:
raise ValueError("Graph name must be provided.")
validate_graph_name(graph_name)
stream, should_close = rdf_payload_to_stream(data)
headers = {
"Content-Type": self._content_type,
Expand Down Expand Up @@ -318,6 +329,7 @@ def clear(self, graph_name: URIRef | str):
"""
if not graph_name:
raise ValueError("Graph name must be provided.")
validate_graph_name(graph_name)
params = self._build_graph_name_params(graph_name) or None
response = self.http_client.delete(self._build_url(graph_name), params=params)
response.raise_for_status()
Expand Down Expand Up @@ -412,9 +424,7 @@ def health(self) -> bool:
f"Repository {self._identifier} is not healthy. {err.response.status_code} - {err.response.text}"
)

def size(
self, graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None
) -> int:
def size(self, graph_name: URIRef | Iterable[URIRef] | str | None = None) -> int:
"""The number of statements in the repository or in the specified graph name.

Parameters:
Expand All @@ -431,6 +441,7 @@ def size(
Raises:
RepositoryFormatError: Fails to parse the repository size.
"""
validate_graph_name(graph_name)
params: dict[str, str] = {}
build_context_param(params, graph_name)
response = self.http_client.get(
Expand Down Expand Up @@ -541,12 +552,16 @@ def get(
subj: SubjectType = None,
pred: PredicateType = None,
obj: ObjectType = None,
graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None,
graph_name: URIRef | Iterable[URIRef] | str | None = None,
infer: bool = True,
content_type: str | None = None,
) -> Graph | Dataset:
"""Get RDF statements from the repository matching the filtering parameters.

!!! Note
The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
[`BNodes`][rdflib.term.BNode].

Parameters:
subj: Subject of the statement to filter by, or `None` to match all.
pred: Predicate of the statement to filter by, or `None` to match all.
Expand All @@ -568,6 +583,7 @@ def get(
A [`Graph`][rdflib.graph.Graph] or [`Dataset`][rdflib.graph.Dataset] object
with the repository namespace prefixes bound to it.
"""
validate_no_bnodes(subj, pred, obj, graph_name)
if content_type is None:
content_type = "application/n-quads"
headers = {"Accept": content_type}
Expand Down Expand Up @@ -632,7 +648,7 @@ def upload(
def overwrite(
self,
data: str | bytes | BinaryIO | Graph | Dataset,
graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None,
graph_name: URIRef | Iterable[URIRef] | str | None = None,
base_uri: str | None = None,
content_type: str | None = None,
):
Expand All @@ -652,7 +668,7 @@ def overwrite(
`application/n-quads` when the value is `None`.
"""
stream, should_close = rdf_payload_to_stream(data)

validate_graph_name(graph_name)
try:
headers = {"Content-Type": content_type or "application/n-quads"}
params: dict[str, str] = {}
Expand All @@ -675,10 +691,14 @@ def delete(
subj: SubjectType = None,
pred: PredicateType = None,
obj: ObjectType = None,
graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None,
graph_name: URIRef | Iterable[URIRef] | str | None = None,
) -> None:
"""Deletes statements from the repository matching the filtering parameters.

!!! Note
The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
[`BNodes`][rdflib.term.BNode].

Parameters:
subj: Subject of the statement to filter by, or `None` to match all.
pred: Predicate of the statement to filter by, or `None` to match all.
Expand All @@ -690,6 +710,7 @@ def delete(
To query just the default graph, use
[`DATASET_DEFAULT_GRAPH_ID`][rdflib.graph.DATASET_DEFAULT_GRAPH_ID].
"""
validate_no_bnodes(subj, pred, obj, graph_name)
params: dict[str, str] = {}
build_context_param(params, graph_name)
build_spo_param(params, subj, pred, obj)
Expand Down Expand Up @@ -808,9 +829,7 @@ def ping(self):
f"Transaction ping failed: {response.status_code} - {response.text}"
)

def size(
self, graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None
):
def size(self, graph_name: URIRef | Iterable[URIRef] | str | None = None):
"""The number of statements in the repository or in the specified graph name.

Parameters:
Expand All @@ -828,6 +847,7 @@ def size(
RepositoryFormatError: Fails to parse the repository size.
"""
self._raise_for_closed()
validate_graph_name(graph_name)
params = {"action": "SIZE"}
build_context_param(params, graph_name)
response = self.repo.http_client.put(self.url, params=params)
Expand Down Expand Up @@ -913,12 +933,16 @@ def get(
subj: SubjectType = None,
pred: PredicateType = None,
obj: ObjectType = None,
graph_name: IdentifiedNode | Iterable[IdentifiedNode] | str | None = None,
graph_name: URIRef | Iterable[URIRef] | str | None = None,
infer: bool = True,
content_type: str | None = None,
) -> Graph | Dataset:
"""Get RDF statements from the repository matching the filtering parameters.

!!! Note
The terms for `subj`, `pred`, `obj` or `graph_name` cannot be
[`BNodes`][rdflib.term.BNode].

Parameters:
subj: Subject of the statement to filter by, or `None` to match all.
pred: Predicate of the statement to filter by, or `None` to match all.
Expand All @@ -940,6 +964,7 @@ def get(
A [`Graph`][rdflib.graph.Graph] or [`Dataset`][rdflib.graph.Dataset] object
with the repository namespace prefixes bound to it.
"""
validate_no_bnodes(subj, pred, obj, graph_name)
if content_type is None:
content_type = "application/n-quads"
headers = {"Accept": content_type}
Expand Down
31 changes: 30 additions & 1 deletion rdflib/contrib/rdf4j/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from rdflib.graph import DATASET_DEFAULT_GRAPH_ID, Dataset, Graph
from rdflib.plugins.sparql.processor import prepareQuery
from rdflib.term import IdentifiedNode, URIRef
from rdflib.term import BNode, IdentifiedNode, URIRef

if t.TYPE_CHECKING:
from rdflib.contrib.rdf4j.client import ObjectType, PredicateType, SubjectType
Expand Down Expand Up @@ -151,3 +151,32 @@ def build_sparql_query_accept_header(query: str, headers: dict[str, str]):
headers["Accept"] = "application/n-triples"
else:
raise ValueError(f"Unsupported query type: {prepared_query.algebra.name}")


def validate_graph_name(graph_name: URIRef | t.Iterable[URIRef] | str | None):
if (
isinstance(graph_name, BNode)
or isinstance(graph_name, t.Iterable)
and any(isinstance(x, BNode) for x in graph_name)
):
raise ValueError("Graph name must not be a BNode.")


def validate_no_bnodes(
subj: SubjectType,
pred: PredicateType,
obj: ObjectType,
graph_name: URIRef | t.Iterable[URIRef] | str | None,
) -> None:
"""Validate that the subject, predicate, and object are not BNodes."""
if (
isinstance(subj, BNode)
or isinstance(pred, BNode)
or isinstance(obj, BNode)
or isinstance(graph_name, BNode)
):
raise ValueError(
"Subject, predicate, and object must not be a BNode: "
f"{subj}, {pred}, {obj}"
)
validate_graph_name(graph_name)
Loading
Loading