Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ When adding new operations:
3. Place in `src/web_algebra/operations/` directory for auto-discovery
4. Use `@op` and `args` structure in JSON examples

### RDF type convention

The system works in the rdflib type system (`Node`, `Result`, `Graph`); JSON is
only the transport. Conversion happens at fixed boundaries — operations do not
pass raw JSON around:

- **`execute()` is pure rdflib** — it takes and returns `Node` / `Result` /
`Graph` only. Never accept a `dict` here.
- **`execute_json()` is the JSON boundary** — it resolves its arguments with
`Operation.process_json()` (which evaluates nested `@op`/variable references)
and then converts any JSON-LD body to a `Graph` with **`Operation.to_graph()`**
before calling `execute()`. `to_graph` is the *single* place JSON-LD is parsed
and the only place an operation applies its base IRI
(`to_graph(data, base=<target url>)`), which is needed to resolve relative
IRIs. An input that is already a `Graph` (e.g. from an upstream `CONSTRUCT`)
passes through unchanged.
- **`process_json()` never parses JSON-LD to a `Graph`** — it resolves
`@op`/variables in place and returns plain data (`dict`/`list`/term). It can't
know an op's base IRI, and eager parsing would freeze unresolved `@op` holes
(an op-valued `@id` would silently become a blank node).

### JSON DSL Format

Operations must follow this structure:
Expand Down
94 changes: 79 additions & 15 deletions src/web_algebra/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
from rdflib.query import Result


# JSON-LD keyword set used to recognise a top-level dict as RDF data rather
# than generic JSON to recurse into. Presence of any of these at the root is
# a strong, unambiguous signal — they are JSON-LD reserved terms with no
# legitimate meaning in non-RDF JSON.
# JSON-LD keyword set used to recognise a dict as RDF data (a JSON-LD
# document/fragment) rather than generic JSON to recurse into. Presence of any
# of these is a strong, unambiguous signal — they are JSON-LD reserved terms
# with no legitimate meaning in non-RDF JSON.
_JSONLD_KEYS = ("@context", "@graph", "@id", "@type")


Expand Down Expand Up @@ -100,18 +100,23 @@ def process_json(
return result

# JSON-LD shape recognition — a dict carrying any JSON-LD reserved
# key is RDF data, not generic JSON to recurse into. Parse it
# once at the runtime layer so every consuming op (POST, PUT,
# Merge, ldh-Create*/Add*) receives an `rdflib.Graph` directly
# instead of re-parsing identically inside its own
# `execute_json`. Symmetric with the bare-value auto-wrap
# below: that branch turns a JSON scalar into the matching
# RDFLib term; this branch turns a JSON-LD object into the
# matching RDFLib graph.
# key is RDF data (a JSON-LD document or fragment), not generic
# JSON to recurse into. It may still embed `@op` operations or
# variable references in its values (e.g. an `@id` computed from a
# runtime binding), so resolve those in place but keep the dict
# intact and let the consuming op parse it.
#
# We deliberately do NOT parse to an `rdflib.Graph` here. Every op
# that consumes a JSON-LD body (POST, PUT, Merge, ldh Create*/Add*)
# already parses a dict itself — and does so with the right base
# IRI (`publicID=<target url>`), which a central parse here cannot
# know. Parsing centrally would also freeze any unresolved `@op`
# holes: `@id` expects a string IRI, so an op-valued `@id` would
# silently become a blank node and the intended URI would be lost.
if any(k in json_data for k in _JSONLD_KEYS):
graph = Graph()
graph.parse(data=json.dumps(json_data), format="json-ld")
return graph
return cls._resolve_jsonld(
settings, json_data, context, variable_stack
)

# 🔁 Recurse into each value — allows nested @op inside generic
# JSON structures (e.g. SPARQL binding objects), without
Expand All @@ -135,6 +140,40 @@ def process_json(
# Convert plain values to RDFLib terms
return cls.json_to_rdflib(json_data)

@classmethod
def _resolve_jsonld(
cls,
settings: BaseSettings,
json_data: Any,
context: dict = {},
variable_stack: list = [],
) -> Any:
"""Resolve embedded `@op` nodes inside a JSON-LD document in place.

Walks a JSON-LD-shaped structure evaluating only `@op` nodes (to their
RDFLib terms — `str` subclasses for URIs/literals, so they serialise
cleanly) while leaving every other scalar as plain JSON for the
consuming op's JSON-LD parser to interpret. Nested dicts/lists keep
their structure so the whole document serialises as one unit; in
particular, fragments that merely look like JSON-LD (e.g. a
`{"@id": "..."}` object reference) stay dicts rather than being parsed
into standalone Graphs.
"""
if isinstance(json_data, dict):
if "@op" in json_data:
return cls.process_json(settings, json_data, context, variable_stack)
return {
k: cls._resolve_jsonld(settings, v, context, variable_stack)
for k, v in json_data.items()
}
elif isinstance(json_data, list):
return [
cls._resolve_jsonld(settings, item, context, variable_stack)
for item in json_data
]
else:
return json_data

@staticmethod
def _serialize_for_json_context(obj) -> Any:
"""Convert RDFLib objects to appropriate format for JSON consumption"""
Expand Down Expand Up @@ -172,6 +211,31 @@ def get_variable(self, name: str, variable_stack: list) -> Any:
raise ValueError(f"Variable '{name}' not found")

# Conversion helpers between different formats
@staticmethod
def to_graph(data: Any, *, base: Optional[str] = None) -> Graph:
"""Convert a JSON-LD document into an `rdflib.Graph`.

The single conversion point from JSON-LD transport into the rdflib
type system. Operations resolve their arguments to plain data (via
`process_json`) and then call this to hand `execute` a `Graph` — so
`execute` always works in rdflib terms, never raw JSON. Input that is
already a `Graph` (e.g. the output of an upstream op such as CONSTRUCT)
passes through unchanged.

`base` is the document's base IRI — typically the target URL of the
enclosing op — used to resolve any relative IRIs in the JSON-LD.
"""
if isinstance(data, Graph):
return data
if isinstance(data, dict):
graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=base)
return graph
raise TypeError(
f"Cannot convert {type(data).__name__} to Graph; "
"expected a JSON-LD dict or an rdflib.Graph"
)

@staticmethod
def json_to_rdflib(data) -> Node:
"""Convert JSON/binding objects to RDFLib terms"""
Expand Down
26 changes: 4 additions & 22 deletions src/web_algebra/operations/linked_data/post.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any
import json
import logging
from rdflib import URIRef, Graph, Literal
from rdflib.namespace import XSD
Expand Down Expand Up @@ -96,34 +95,17 @@ def execute_json(self, arguments: dict, variable_stack: list = []) -> Result:
self.settings, arguments["data"], self.context, variable_stack
)

# Convert processed data to Graph object
if isinstance(data_result, Graph):
# Already a Graph (from some operation result)
graph_data = data_result
elif isinstance(data_result, dict):
# Processed JSON-LD - convert to Graph
import json

json_str = json.dumps(data_result)
graph = Graph()
graph.parse(data=json_str, format="json-ld", publicID=str(url_data))
graph_data = graph
else:
raise TypeError(
f"POST operation expects data to be Graph or dict, got {type(data_result)}"
)
# Convert resolved JSON-LD into a Graph at the target's base IRI
graph_data = self.to_graph(data_result, base=str(url_data))

return self.execute(url_data, graph_data)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
"""MCP execution: plain args → plain results"""
url = URIRef(arguments["url"])

# Convert JSON-LD data to Graph
data_dict = arguments["data"]
json_str = json.dumps(data_dict)
graph = Graph()
graph.parse(data=json_str, format="json-ld", publicID=str(url))
# Convert JSON-LD data to Graph at the target's base IRI
graph = self.to_graph(arguments["data"], base=str(url))

result = self.execute(url, graph)

Expand Down
26 changes: 4 additions & 22 deletions src/web_algebra/operations/linked_data/put.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any
import json
import logging
from rdflib import URIRef, Graph, Literal
from rdflib.namespace import XSD
Expand Down Expand Up @@ -96,34 +95,17 @@ def execute_json(self, arguments: dict, variable_stack: list = []) -> Result:
self.settings, arguments["data"], self.context, variable_stack
)

# Convert processed data to Graph object
if isinstance(data_result, Graph):
# Already a Graph (from some operation result)
graph_data = data_result
elif isinstance(data_result, dict):
# Processed JSON-LD - convert to Graph
import json

json_str = json.dumps(data_result)
graph = Graph()
graph.parse(data=json_str, format="json-ld", publicID=str(url_data))
graph_data = graph
else:
raise TypeError(
f"PUT operation expects data to be Graph or dict, got {type(data_result)}"
)
# Convert resolved JSON-LD into a Graph at the target's base IRI
graph_data = self.to_graph(data_result, base=str(url_data))

return self.execute(url_data, graph_data)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
"""MCP execution: plain args → plain results"""
url = URIRef(arguments["url"])

# Convert JSON-LD data to Graph
data_dict = arguments["data"]
json_str = json.dumps(data_dict)
graph = Graph()
graph.parse(data=json_str, format="json-ld", publicID=str(url))
# Convert JSON-LD data to Graph at the target's base IRI
graph = self.to_graph(arguments["data"], base=str(url))

result = self.execute(url, graph)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,8 @@ def execute(

logging.info(f"Posting service description with JSON-LD data: {data}")

# POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
import json
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,8 @@ def execute(

logging.info(f"Posting ResultSetChart with JSON-LD data: {data}")

# POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
import json
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
9 changes: 2 additions & 7 deletions src/web_algebra/operations/linkeddatahub/add_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,8 @@ def execute(

logging.info(f"Posting SELECT query with JSON-LD data: {data}")

# POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
import json
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
9 changes: 2 additions & 7 deletions src/web_algebra/operations/linkeddatahub/add_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,8 @@ def execute(

logging.info(f"Posting View with JSON-LD data: {data}")

# POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
import json
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,8 @@ def execute(

logging.info(f"Posting object block with JSON-LD data: {data}")

# Step 6: POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Step 6: Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,8 @@ def execute(

logging.info(f"Posting XHTML block with JSON-LD data: {data}")

# Step 6: POST the JSON-LD content to the target URI
# Convert JSON-LD dict to Graph
from rdflib import Graph

graph = Graph()
graph.parse(data=json.dumps(data), format="json-ld", publicID=url_str)
# Step 6: Convert the JSON-LD content to a Graph and POST to the target URI
graph = self.to_graph(data, base=url_str)
return super().execute(url, graph)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,13 @@ def _build_view_graph(self, view_uri: URIRef, class_local: str, query_uri: URIRe

def execute_json(self, arguments: dict, variable_stack: list = []) -> Result:
"""JSON execution: process arguments with type checking"""
# Process ontology graph
ontology_data = Operation.process_json(
self.settings, arguments["ontology"], self.context, variable_stack
)

if not isinstance(ontology_data, Graph):
raise TypeError(
f"GenerateClassContainers operation expects 'ontology' to be Graph, got {type(ontology_data)}"
# Process ontology graph — accept a Graph (e.g. from CONSTRUCT) or a
# JSON-LD document, converting the latter to a Graph
ontology_data = self.to_graph(
Operation.process_json(
self.settings, arguments["ontology"], self.context, variable_stack
)
)

# Process parent_container
parent_container_data = Operation.process_json(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,13 @@ def _generate_sparql_query(self, property_uri: URIRef) -> str:

def execute_json(self, arguments: dict, variable_stack: list = []) -> Graph:
"""JSON execution: process arguments with type checking"""
# Process ontology graph
ontology_data = Operation.process_json(
self.settings, arguments["ontology"], self.context, variable_stack
)

if not isinstance(ontology_data, Graph):
raise TypeError(
f"GenerateOntologyViews operation expects 'ontology' to be Graph, got {type(ontology_data)}"
# Process ontology graph — accept a Graph (e.g. from CONSTRUCT) or a
# JSON-LD document, converting the latter to a Graph
ontology_data = self.to_graph(
Operation.process_json(
self.settings, arguments["ontology"], self.context, variable_stack
)
)

# Process base_uri
base_uri_data = Operation.process_json(
Expand Down
Loading
Loading