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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to Web Algebra are recorded here. The format follows
[Semantic Versioning](https://semver.org/). The normative definition of every
operation is [formal-semantics.md](formal-semantics.md).

## [Unreleased]

### Changed

- **`ldh-CreateContainer` and `ldh-CreateItem` only create**: the `PUT`
carries `If-None-Match: *` and no `If-Match`, as LinkedDataHub's CLI create
commands and its client's create forms send it, so a document already at
the URI is refused `412` and left as it is, and the operation fails. They
read the document's entity tag and quoted it before, which made a create on
an existing URI replace the document whole, its content blocks with it. A
new `if_absent` argument (xsd:boolean) accepts the existing document as the
result instead, like `mkdir -p`, and does not count it as a write to an
enclosing `ForEach`. Their kind is `write`, no longer `destructive`. A
pipeline that relied on a create replacing a document `PUT`s it;
`ldh-GenerateClassContainers`, which creates through `ldh-CreateItem`, now
stops on a class whose item already exists instead of replacing it.

## [2.0.2] - 2026-10-28

### Added
Expand Down
15 changes: 11 additions & 4 deletions formal-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1197,8 +1197,8 @@ operations' MCP tools, and an MCP tool name allows only ASCII letters, digits,

| Operation | JSON args (`Maybe` = optional) |
|-----------|--------------------------------|
| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · children_view: Maybe Literal (xsd:boolean) · primary_topic: Maybe Literal` |
| `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · primary_topic: Maybe Literal` |
| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · children_view: Maybe Literal (xsd:boolean) · primary_topic: Maybe Literal · if_absent: Maybe Literal (xsd:boolean)` |
| `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · primary_topic: Maybe Literal · if_absent: Maybe Literal (xsd:boolean)` |
| `ldh-List` *(query)* | `url: URI · endpoint: URI` (or `base: Literal`, from which `endpoint` = `base` + `sparql`) |
| `ldh-AddFile` | `url: URI · file: Literal (path) · title: Literal · description: Maybe Literal · content_type: Maybe Literal` |
| `ldh-AddGenericService` | `url: URI · endpoint: URI · title: Literal · description/fragment: Maybe Literal · graph_store: Maybe URI · auth_user/auth_pwd: Maybe Literal` |
Expand All @@ -1215,8 +1215,15 @@ operations' MCP tools, and an MCP tool name allows only ASCII letters, digits,

`ldh-CreateContainer` and `ldh-CreateItem` write the document at the parent's
URI extended by `slug` and `/`; without a `slug` the segment is a fresh
lowercase UUID, as in the LinkedDataHub CLI. The write is a `PUT`, so a
document already at that URI is replaced.
lowercase UUID, as in the LinkedDataHub CLI. They only create: the write is
a `PUT` with `If-None-Match: *` and no `If-Match` (§4.4), as LinkedDataHub's
CLI create commands and its client's create forms send it, so a server
refuses it `412` where a document already exists and leaves that document as
it is. The refusal is the operation's error (§3.7), unless `if_absent` is
true: then the existing document is the result, as an existing directory is
for `mkdir -p` — the single row of §4.4 with `status` 412 and the document's
`url`. Nothing was written, so that `url` is not reported to an enclosing
`ForEach` as a write (§3.6). Replacing a document is `PUT`'s.

What the operations write is what LinkedDataHub's CLI writes for the same
inputs (its `rdf` module), so a document made either way is the same:
Expand Down
20 changes: 19 additions & 1 deletion src/web_algebra/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,18 +210,22 @@ def _write(
body: Optional[bytes] = None,
content_type: Optional[str] = None,
accept: str = "application/n-triples",
preconditions: Optional[dict] = None,
) -> HTTPResponse:
"""Send a write, conditional on the resource's current entity tag.

Every mutating request goes through here, whatever its body, so none of
them can leave out the `If-Match` a server like LinkedDataHub requires of
a write to an existing document (formal-semantics.md §4.4) - as the
LinkedDataHub CLI's `LDHClient` adds it in each of its write methods.
A write that brings its own `preconditions` is sent with those instead:
`If-None-Match: *` creates only, and quoting an existing tag as well
would turn it back into a replace.
"""
headers = {"Accept": accept}
if content_type is not None:
headers["Content-Type"] = content_type
headers.update(self.conditional(url, accept))
headers.update(preconditions if preconditions is not None else self.conditional(url, accept))
request = urllib.request.Request(url, data=body, headers=headers, method=method)

return self._send(request)
Expand Down Expand Up @@ -311,6 +315,20 @@ def put(self, url: str, graph: Graph) -> HTTPResponse:
data = graph.serialize(format="nt")
return self._write("PUT", url, data.encode("utf-8"), "application/n-triples")

def create(self, url: str, graph: Graph) -> HTTPResponse:
"""
Sends RDF data to the given URL using HTTP PUT with `If-None-Match: *`,
so that it only ever creates: a server answers `412` where a resource
already exists, and leaves it as it is (formal-semantics.md Appendix A).

:param url: The URL to create the resource at.
:param graph: An RDFLib Graph containing the data to send.
:return: The HTTPResponse object.
"""
data = graph.serialize(format="nt")
return self._write("PUT", url, data.encode("utf-8"), "application/n-triples",
preconditions={"If-None-Match": "*"})

def delete(self, url: str) -> HTTPResponse:
"""
Sends an HTTP DELETE request to the given URL.
Expand Down
51 changes: 51 additions & 0 deletions src/web_algebra/operations/linkeddatahub/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import logging
from typing import Optional

from rdflib import Graph, Literal, URIRef
from rdflib.namespace import XSD
from rdflib.query import Result

from web_algebra.client_operation import ClientOperation
from web_algebra.exceptions import WriteRefusedError
from web_algebra.json_result import JSONResult

PRECONDITION_FAILED = 412


def create(op: ClientOperation, url: URIRef, graph: Graph, if_absent: Optional[Literal]) -> Result:
"""Write `graph` as a new document at `url`, never replacing one.

The `PUT` carries `If-None-Match: *` (formal-semantics.md Appendix A), as
LinkedDataHub's CLI create commands and its client's create forms send it,
so a server refuses it `412` where a document already exists and leaves
that document as it is. That refusal is the operation's error, unless
`if_absent` is true: then the existing document is the result, as an
existing directory is for `mkdir -p`. Nothing was written, so it is not
reported to an enclosing `ForEach` as a write (§3.6).
"""
try:
response = op.client.create(str(url), graph)
except WriteRefusedError as e:
if e.status != PRECONDITION_FAILED:
raise
if if_absent is None or not if_absent.toPython():
raise WriteRefusedError(
e.method, e.url, e.status,
"a document already exists at this URI and was left as it is; "
"pass if_absent to accept one that does",
) from None
logging.info("A document already exists at <%s>; left as it is", url)
return JSONResult(
vars=["status", "url"],
bindings=[{"status": Literal(e.status, datatype=XSD.integer), "url": url}],
)

logging.info("PUT operation status: %s", response.status)
return op.written_response(response)


def if_absent_flag(value: Optional[Literal], op_name: str) -> Optional[Literal]:
"""`if_absent` as an operation takes it: absent, or an xsd:boolean Literal."""
if value is not None and not (isinstance(value, Literal) and value.datatype == XSD.boolean):
raise TypeError(f"{op_name} expects if_absent to be an xsd:boolean Literal, got {value!r}")
return value
43 changes: 34 additions & 9 deletions src/web_algebra/operations/linkeddatahub/create_container.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, ClassVar, Optional
import logging
import urllib.parse
import uuid
Expand All @@ -7,11 +7,17 @@
from mcp import types
from web_algebra.operation import Operation
from web_algebra.rfc3986 import resolve
from web_algebra.operation import OperationKind
from web_algebra.operations.linked_data.put import PUT
from web_algebra.operations.linkeddatahub.create import create, if_absent_flag
from rdflib.query import Result


class CreateContainer(PUT):
# creates only, with If-None-Match: *: a document already at the URI is left as it is, so
# nothing is ever removed
kind: ClassVar[OperationKind] = OperationKind.WRITE

@classmethod
def name(cls):
return "ldh-CreateContainer"
Expand All @@ -26,7 +32,9 @@ def description(cls) -> str:
- Example: if https://localhost/blog/ exists, you cannot create https://localhost/blog/posts/something/
before creating https://localhost/blog/posts/
- LinkedDataHub automatically manages timestamps (dct:created, dct:modified)
- LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy"""
- LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy
- It only creates: a document already at the URI is left as it is and the operation fails, unless
if_absent is true, which accepts it as the result (like mkdir -p). To replace a document, PUT it."""

@classmethod
def inputSchema(cls) -> dict:
Expand All @@ -43,7 +51,7 @@ def inputSchema(cls) -> dict:
},
"slug": {
"type": "string",
"description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.",
"description": "URL path segment (optional). Defaults to a random UUID.",
},
"description": {
"type": "string",
Expand All @@ -57,6 +65,10 @@ def inputSchema(cls) -> dict:
"type": "string",
"description": "URI reference of what the container is about, resolved against its URI (optional), e.g. #this.",
},
"if_absent": {
"type": "boolean",
"description": "Optional: whether a document already at the URI counts as success, left as it is (like mkdir -p). Without it the operation fails there.",
},
},
"required": ["parent", "title"],
}
Expand All @@ -69,6 +81,7 @@ def execute(
description: Optional[Literal] = None,
children_view: Optional[Literal] = None,
primary_topic: Optional[Literal] = None,
if_absent: Optional[Literal] = None,
) -> Result:
"""Pure function: create container with RDFLib terms"""
if not isinstance(parent_uri, URIRef):
Expand Down Expand Up @@ -98,6 +111,8 @@ def execute(
f"CreateContainer.execute expects primary_topic to be Literal or None, got {type(primary_topic)}"
)

if_absent = if_absent_flag(if_absent, "CreateContainer.execute")

# URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that
# leaving it out never lands on an existing document; deriving it from the title did, on a repeated title
slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="")
Expand Down Expand Up @@ -141,8 +156,7 @@ def execute(
# Convert the document to a Graph and call parent PUT operation
graph = self.to_graph(data, base=url)

# Call parent PUT execute method
return super().execute(URIRef(url), graph)
return create(self, URIRef(url), graph, if_absent)

def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
"""JSON execution: process arguments and call pure function"""
Expand Down Expand Up @@ -210,7 +224,14 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
f"ldh-CreateContainer operation expects 'primary_topic' to be Literal, got {type(primary_topic)}"
)

return self.execute(parent_uri, title, slug, description, children_view, primary_topic)
# Process optional if_absent
if_absent = None
if "if_absent" in arguments:
if_absent = Operation.json_to_rdflib(Operation.process_json(
self.settings, arguments["if_absent"], self.context, variable_stack
))

return self.execute(parent_uri, title, slug, description, children_view, primary_topic, if_absent)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
"""MCP execution: plain args → plain results"""
Expand All @@ -235,12 +256,16 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any:
if "primary_topic" in arguments
else None
)
result = self.execute(parent_uri, title, slug, description, children_view, primary_topic)
if_absent = Literal(bool(arguments["if_absent"])) if "if_absent" in arguments else None
result = self.execute(parent_uri, title, slug, description, children_view, primary_topic, if_absent)

# Extract URL from PUT result
# Extract URL from PUT result; a 412 is the existing document if_absent accepted
url_binding = result.bindings[0]["url"]
created = int(result.bindings[0]["status"]) != 412
return [
types.TextContent(
type="text", text=f"Container created successfully at: {url_binding}"
type="text",
text=f"Container created successfully at: {url_binding}" if created
else f"A document already exists at {url_binding}; left as it is",
)
]
43 changes: 34 additions & 9 deletions src/web_algebra/operations/linkeddatahub/create_item.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, ClassVar, Optional
import logging
import urllib.parse
import uuid
Expand All @@ -7,11 +7,17 @@
from mcp import types
from web_algebra.operation import Operation
from web_algebra.rfc3986 import resolve
from web_algebra.operation import OperationKind
from web_algebra.operations.linked_data.put import PUT
from web_algebra.operations.linkeddatahub.create import create, if_absent_flag
from rdflib.query import Result


class CreateItem(PUT):
# creates only, with If-None-Match: *: a document already at the URI is left as it is, so
# nothing is ever removed
kind: ClassVar[OperationKind] = OperationKind.WRITE

@classmethod
def name(cls):
return "ldh-CreateItem"
Expand All @@ -26,7 +32,9 @@ def description(cls) -> str:
- Example: if https://localhost/blog/ exists, you cannot create https://localhost/blog/posts/something/
before creating https://localhost/blog/posts/
- LinkedDataHub automatically manages timestamps (dct:created, dct:modified)
- LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy"""
- LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy
- It only creates: a document already at the URI is left as it is and the operation fails, unless
if_absent is true, which accepts it as the result (like mkdir -p). To replace a document, PUT it."""

@classmethod
def inputSchema(cls) -> dict:
Expand All @@ -40,13 +48,17 @@ def inputSchema(cls) -> dict:
"title": {"type": "string", "description": "The title of the item"},
"slug": {
"type": "string",
"description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.",
"description": "URL path segment (optional). Defaults to a random UUID.",
},
"description": {"type": "string", "description": "Description (optional)"},
"primary_topic": {
"type": "string",
"description": "URI reference of what the item is about, resolved against its URI (optional), e.g. #this.",
},
"if_absent": {
"type": "boolean",
"description": "Optional: whether a document already at the URI counts as success, left as it is (like mkdir -p). Without it the operation fails there.",
},
},
"required": ["container", "title"],
}
Expand All @@ -58,6 +70,7 @@ def execute(
slug: Optional[Literal] = None,
description: Optional[Literal] = None,
primary_topic: Optional[Literal] = None,
if_absent: Optional[Literal] = None,
) -> Result:
"""Pure function: create item with RDFLib terms"""
if not isinstance(container_uri, URIRef):
Expand All @@ -82,6 +95,8 @@ def execute(
f"CreateItem.execute expects primary_topic to be Literal or None, got {type(primary_topic)}"
)

if_absent = if_absent_flag(if_absent, "CreateItem.execute")

# URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that
# leaving it out never lands on an existing document; deriving it from the title did, on a repeated title
slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="")
Expand Down Expand Up @@ -118,8 +133,7 @@ def execute(
# Convert the document to a Graph and call parent PUT operation
graph = self.to_graph(data, base=url)

# Call parent PUT execute method
return super().execute(URIRef(url), graph)
return create(self, URIRef(url), graph, if_absent)

def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
"""JSON execution: process arguments with strict type checking"""
Expand Down Expand Up @@ -180,7 +194,14 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
f"ldh-CreateItem operation expects 'primary_topic' to be Literal, got {type(primary_topic)}"
)

return self.execute(container_uri, title, slug, description, primary_topic)
# Process optional if_absent
if_absent = None
if "if_absent" in arguments:
if_absent = Operation.json_to_rdflib(Operation.process_json(
self.settings, arguments["if_absent"], self.context, variable_stack
))

return self.execute(container_uri, title, slug, description, primary_topic, if_absent)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
"""MCP execution: plain args → plain results"""
Expand All @@ -202,12 +223,16 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any:
if "primary_topic" in arguments
else None
)
result = self.execute(container_uri, title, slug, description, primary_topic)
if_absent = Literal(bool(arguments["if_absent"])) if "if_absent" in arguments else None
result = self.execute(container_uri, title, slug, description, primary_topic, if_absent)

# Extract URL from PUT result
# Extract URL from PUT result; a 412 is the existing document if_absent accepted
url_binding = result.bindings[0]["url"]
created = int(result.bindings[0]["status"]) != 412
return [
types.TextContent(
type="text", text=f"Item created successfully at: {url_binding}"
type="text",
text=f"Item created successfully at: {url_binding}" if created
else f"A document already exists at {url_binding}; left as it is",
)
]
Loading
Loading