web-algebra 2.0.2__tar.gz → 2.0.3__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 (95) hide show
  1. {web_algebra-2.0.2 → web_algebra-2.0.3}/PKG-INFO +1 -1
  2. {web_algebra-2.0.2 → web_algebra-2.0.3}/pyproject.toml +1 -1
  3. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/client.py +19 -1
  4. web_algebra-2.0.3/src/web_algebra/operations/linkeddatahub/create.py +51 -0
  5. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/create_container.py +34 -9
  6. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/create_item.py +34 -9
  7. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/prompts/system.md +4 -4
  8. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/PKG-INFO +1 -1
  9. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/SOURCES.txt +1 -0
  10. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_operation_kind.py +5 -5
  11. {web_algebra-2.0.2 → web_algebra-2.0.3}/LICENSE +0 -0
  12. {web_algebra-2.0.2 → web_algebra-2.0.3}/README.md +0 -0
  13. {web_algebra-2.0.2 → web_algebra-2.0.3}/setup.cfg +0 -0
  14. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/__init__.py +0 -0
  15. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/__main__.py +0 -0
  16. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/client_operation.py +0 -0
  17. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/exceptions.py +0 -0
  18. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/focus.py +0 -0
  19. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/json_result.py +0 -0
  20. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/main.py +0 -0
  21. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/mcp_tool.py +0 -0
  22. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operation.py +0 -0
  23. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/__init__.py +0 -0
  24. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/bindings.py +0 -0
  25. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/current.py +0 -0
  26. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/filter.py +0 -0
  27. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/for_each.py +0 -0
  28. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/iterate.py +0 -0
  29. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/last.py +0 -0
  30. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linked_data/__init__.py +0 -0
  31. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linked_data/get.py +0 -0
  32. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linked_data/patch.py +0 -0
  33. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linked_data/post.py +0 -0
  34. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linked_data/put.py +0 -0
  35. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/__init__.py +0 -0
  36. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_construct.py +0 -0
  37. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_file.py +0 -0
  38. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_generic_service.py +0 -0
  39. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py +0 -0
  40. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_select.py +0 -0
  41. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/add_view.py +0 -0
  42. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/__init__.py +0 -0
  43. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/add_object_block.py +0 -0
  44. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/add_xhtml_block.py +0 -0
  45. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/generate_class_containers.py +0 -0
  46. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/generate_ontology_views.py +0 -0
  47. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/generate_portal.py +0 -0
  48. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/content/remove_block.py +0 -0
  49. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/fragments.py +0 -0
  50. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/linkeddatahub/list.py +0 -0
  51. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/merge.py +0 -0
  52. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/now.py +0 -0
  53. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/position.py +0 -0
  54. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/resolve_uri.py +0 -0
  55. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/schema/__init__.py +0 -0
  56. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/schema/extract_classes.py +0 -0
  57. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/schema/extract_datatype_properties.py +0 -0
  58. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/schema/extract_object_properties.py +0 -0
  59. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/schema/extract_ontology.py +0 -0
  60. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/__init__.py +0 -0
  61. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/construct.py +0 -0
  62. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/describe.py +0 -0
  63. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/select.py +0 -0
  64. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/substitute.py +0 -0
  65. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql/values.py +0 -0
  66. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/sparql_string.py +0 -0
  67. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/str.py +0 -0
  68. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/string/__init__.py +0 -0
  69. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/string/concat.py +0 -0
  70. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/string/encode_for_uri.py +0 -0
  71. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/string/replace.py +0 -0
  72. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/struuid.py +0 -0
  73. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/uri.py +0 -0
  74. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/value.py +0 -0
  75. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/operations/variable.py +0 -0
  76. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/prompts/user.template.txt +0 -0
  77. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/py.typed +0 -0
  78. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/query_source.py +0 -0
  79. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/rfc3986.py +0 -0
  80. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/schema_extraction.py +0 -0
  81. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/server.py +0 -0
  82. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra/sparql_restrict.py +0 -0
  83. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/dependency_links.txt +0 -0
  84. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/entry_points.txt +0 -0
  85. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/requires.txt +0 -0
  86. {web_algebra-2.0.2 → web_algebra-2.0.3}/src/web_algebra.egg-info/top_level.txt +0 -0
  87. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_client_writes.py +0 -0
  88. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_examples.py +0 -0
  89. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_ldh_cli_parity.py +0 -0
  90. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_mcp_server.py +0 -0
  91. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_operation_names.py +0 -0
  92. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_packaging.py +0 -0
  93. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_planner.py +0 -0
  94. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_sparql_string_ask.py +0 -0
  95. {web_algebra-2.0.2 → web_algebra-2.0.3}/tests/test_system_prompt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-algebra
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Composable RDF operations in JSON
5
5
  Author-email: Martynas Jusevicius <martynas@atomgraph.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "web-algebra"
3
- version = "2.0.2"
3
+ version = "2.0.3"
4
4
  description = "Composable RDF operations in JSON"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -210,6 +210,7 @@ class LinkedDataClient:
210
210
  body: Optional[bytes] = None,
211
211
  content_type: Optional[str] = None,
212
212
  accept: str = "application/n-triples",
213
+ preconditions: Optional[dict] = None,
213
214
  ) -> HTTPResponse:
214
215
  """Send a write, conditional on the resource's current entity tag.
215
216
 
@@ -217,11 +218,14 @@ class LinkedDataClient:
217
218
  them can leave out the `If-Match` a server like LinkedDataHub requires of
218
219
  a write to an existing document (formal-semantics.md §4.4) - as the
219
220
  LinkedDataHub CLI's `LDHClient` adds it in each of its write methods.
221
+ A write that brings its own `preconditions` is sent with those instead:
222
+ `If-None-Match: *` creates only, and quoting an existing tag as well
223
+ would turn it back into a replace.
220
224
  """
221
225
  headers = {"Accept": accept}
222
226
  if content_type is not None:
223
227
  headers["Content-Type"] = content_type
224
- headers.update(self.conditional(url, accept))
228
+ headers.update(preconditions if preconditions is not None else self.conditional(url, accept))
225
229
  request = urllib.request.Request(url, data=body, headers=headers, method=method)
226
230
 
227
231
  return self._send(request)
@@ -311,6 +315,20 @@ class LinkedDataClient:
311
315
  data = graph.serialize(format="nt")
312
316
  return self._write("PUT", url, data.encode("utf-8"), "application/n-triples")
313
317
 
318
+ def create(self, url: str, graph: Graph) -> HTTPResponse:
319
+ """
320
+ Sends RDF data to the given URL using HTTP PUT with `If-None-Match: *`,
321
+ so that it only ever creates: a server answers `412` where a resource
322
+ already exists, and leaves it as it is (formal-semantics.md Appendix A).
323
+
324
+ :param url: The URL to create the resource at.
325
+ :param graph: An RDFLib Graph containing the data to send.
326
+ :return: The HTTPResponse object.
327
+ """
328
+ data = graph.serialize(format="nt")
329
+ return self._write("PUT", url, data.encode("utf-8"), "application/n-triples",
330
+ preconditions={"If-None-Match": "*"})
331
+
314
332
  def delete(self, url: str) -> HTTPResponse:
315
333
  """
316
334
  Sends an HTTP DELETE request to the given URL.
@@ -0,0 +1,51 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+ from rdflib import Graph, Literal, URIRef
5
+ from rdflib.namespace import XSD
6
+ from rdflib.query import Result
7
+
8
+ from web_algebra.client_operation import ClientOperation
9
+ from web_algebra.exceptions import WriteRefusedError
10
+ from web_algebra.json_result import JSONResult
11
+
12
+ PRECONDITION_FAILED = 412
13
+
14
+
15
+ def create(op: ClientOperation, url: URIRef, graph: Graph, if_absent: Optional[Literal]) -> Result:
16
+ """Write `graph` as a new document at `url`, never replacing one.
17
+
18
+ The `PUT` carries `If-None-Match: *` (formal-semantics.md Appendix A), as
19
+ LinkedDataHub's CLI create commands and its client's create forms send it,
20
+ so a server refuses it `412` where a document already exists and leaves
21
+ that document as it is. That refusal is the operation's error, unless
22
+ `if_absent` is true: then the existing document is the result, as an
23
+ existing directory is for `mkdir -p`. Nothing was written, so it is not
24
+ reported to an enclosing `ForEach` as a write (§3.6).
25
+ """
26
+ try:
27
+ response = op.client.create(str(url), graph)
28
+ except WriteRefusedError as e:
29
+ if e.status != PRECONDITION_FAILED:
30
+ raise
31
+ if if_absent is None or not if_absent.toPython():
32
+ raise WriteRefusedError(
33
+ e.method, e.url, e.status,
34
+ "a document already exists at this URI and was left as it is; "
35
+ "pass if_absent to accept one that does",
36
+ ) from None
37
+ logging.info("A document already exists at <%s>; left as it is", url)
38
+ return JSONResult(
39
+ vars=["status", "url"],
40
+ bindings=[{"status": Literal(e.status, datatype=XSD.integer), "url": url}],
41
+ )
42
+
43
+ logging.info("PUT operation status: %s", response.status)
44
+ return op.written_response(response)
45
+
46
+
47
+ def if_absent_flag(value: Optional[Literal], op_name: str) -> Optional[Literal]:
48
+ """`if_absent` as an operation takes it: absent, or an xsd:boolean Literal."""
49
+ if value is not None and not (isinstance(value, Literal) and value.datatype == XSD.boolean):
50
+ raise TypeError(f"{op_name} expects if_absent to be an xsd:boolean Literal, got {value!r}")
51
+ return value
@@ -1,4 +1,4 @@
1
- from typing import Any, Optional
1
+ from typing import Any, ClassVar, Optional
2
2
  import logging
3
3
  import urllib.parse
4
4
  import uuid
@@ -7,11 +7,17 @@ from rdflib.namespace import XSD
7
7
  from mcp import types
8
8
  from web_algebra.operation import Operation
9
9
  from web_algebra.rfc3986 import resolve
10
+ from web_algebra.operation import OperationKind
10
11
  from web_algebra.operations.linked_data.put import PUT
12
+ from web_algebra.operations.linkeddatahub.create import create, if_absent_flag
11
13
  from rdflib.query import Result
12
14
 
13
15
 
14
16
  class CreateContainer(PUT):
17
+ # creates only, with If-None-Match: *: a document already at the URI is left as it is, so
18
+ # nothing is ever removed
19
+ kind: ClassVar[OperationKind] = OperationKind.WRITE
20
+
15
21
  @classmethod
16
22
  def name(cls):
17
23
  return "ldh-CreateContainer"
@@ -26,7 +32,9 @@ class CreateContainer(PUT):
26
32
  - Example: if https://localhost/blog/ exists, you cannot create https://localhost/blog/posts/something/
27
33
  before creating https://localhost/blog/posts/
28
34
  - LinkedDataHub automatically manages timestamps (dct:created, dct:modified)
29
- - LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy"""
35
+ - LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy
36
+ - It only creates: a document already at the URI is left as it is and the operation fails, unless
37
+ if_absent is true, which accepts it as the result (like mkdir -p). To replace a document, PUT it."""
30
38
 
31
39
  @classmethod
32
40
  def inputSchema(cls) -> dict:
@@ -43,7 +51,7 @@ class CreateContainer(PUT):
43
51
  },
44
52
  "slug": {
45
53
  "type": "string",
46
- "description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.",
54
+ "description": "URL path segment (optional). Defaults to a random UUID.",
47
55
  },
48
56
  "description": {
49
57
  "type": "string",
@@ -57,6 +65,10 @@ class CreateContainer(PUT):
57
65
  "type": "string",
58
66
  "description": "URI reference of what the container is about, resolved against its URI (optional), e.g. #this.",
59
67
  },
68
+ "if_absent": {
69
+ "type": "boolean",
70
+ "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.",
71
+ },
60
72
  },
61
73
  "required": ["parent", "title"],
62
74
  }
@@ -69,6 +81,7 @@ class CreateContainer(PUT):
69
81
  description: Optional[Literal] = None,
70
82
  children_view: Optional[Literal] = None,
71
83
  primary_topic: Optional[Literal] = None,
84
+ if_absent: Optional[Literal] = None,
72
85
  ) -> Result:
73
86
  """Pure function: create container with RDFLib terms"""
74
87
  if not isinstance(parent_uri, URIRef):
@@ -98,6 +111,8 @@ class CreateContainer(PUT):
98
111
  f"CreateContainer.execute expects primary_topic to be Literal or None, got {type(primary_topic)}"
99
112
  )
100
113
 
114
+ if_absent = if_absent_flag(if_absent, "CreateContainer.execute")
115
+
101
116
  # URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that
102
117
  # leaving it out never lands on an existing document; deriving it from the title did, on a repeated title
103
118
  slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="")
@@ -141,8 +156,7 @@ class CreateContainer(PUT):
141
156
  # Convert the document to a Graph and call parent PUT operation
142
157
  graph = self.to_graph(data, base=url)
143
158
 
144
- # Call parent PUT execute method
145
- return super().execute(URIRef(url), graph)
159
+ return create(self, URIRef(url), graph, if_absent)
146
160
 
147
161
  def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
148
162
  """JSON execution: process arguments and call pure function"""
@@ -210,7 +224,14 @@ class CreateContainer(PUT):
210
224
  f"ldh-CreateContainer operation expects 'primary_topic' to be Literal, got {type(primary_topic)}"
211
225
  )
212
226
 
213
- return self.execute(parent_uri, title, slug, description, children_view, primary_topic)
227
+ # Process optional if_absent
228
+ if_absent = None
229
+ if "if_absent" in arguments:
230
+ if_absent = Operation.json_to_rdflib(Operation.process_json(
231
+ self.settings, arguments["if_absent"], self.context, variable_stack
232
+ ))
233
+
234
+ return self.execute(parent_uri, title, slug, description, children_view, primary_topic, if_absent)
214
235
 
215
236
  def mcp_run(self, arguments: dict, context: Any = None) -> Any:
216
237
  """MCP execution: plain args → plain results"""
@@ -235,12 +256,16 @@ class CreateContainer(PUT):
235
256
  if "primary_topic" in arguments
236
257
  else None
237
258
  )
238
- result = self.execute(parent_uri, title, slug, description, children_view, primary_topic)
259
+ if_absent = Literal(bool(arguments["if_absent"])) if "if_absent" in arguments else None
260
+ result = self.execute(parent_uri, title, slug, description, children_view, primary_topic, if_absent)
239
261
 
240
- # Extract URL from PUT result
262
+ # Extract URL from PUT result; a 412 is the existing document if_absent accepted
241
263
  url_binding = result.bindings[0]["url"]
264
+ created = int(result.bindings[0]["status"]) != 412
242
265
  return [
243
266
  types.TextContent(
244
- type="text", text=f"Container created successfully at: {url_binding}"
267
+ type="text",
268
+ text=f"Container created successfully at: {url_binding}" if created
269
+ else f"A document already exists at {url_binding}; left as it is",
245
270
  )
246
271
  ]
@@ -1,4 +1,4 @@
1
- from typing import Any, Optional
1
+ from typing import Any, ClassVar, Optional
2
2
  import logging
3
3
  import urllib.parse
4
4
  import uuid
@@ -7,11 +7,17 @@ from rdflib.namespace import XSD
7
7
  from mcp import types
8
8
  from web_algebra.operation import Operation
9
9
  from web_algebra.rfc3986 import resolve
10
+ from web_algebra.operation import OperationKind
10
11
  from web_algebra.operations.linked_data.put import PUT
12
+ from web_algebra.operations.linkeddatahub.create import create, if_absent_flag
11
13
  from rdflib.query import Result
12
14
 
13
15
 
14
16
  class CreateItem(PUT):
17
+ # creates only, with If-None-Match: *: a document already at the URI is left as it is, so
18
+ # nothing is ever removed
19
+ kind: ClassVar[OperationKind] = OperationKind.WRITE
20
+
15
21
  @classmethod
16
22
  def name(cls):
17
23
  return "ldh-CreateItem"
@@ -26,7 +32,9 @@ class CreateItem(PUT):
26
32
  - Example: if https://localhost/blog/ exists, you cannot create https://localhost/blog/posts/something/
27
33
  before creating https://localhost/blog/posts/
28
34
  - LinkedDataHub automatically manages timestamps (dct:created, dct:modified)
29
- - LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy"""
35
+ - LinkedDataHub automatically manages parent-child relationships (sioc:has_parent) based on URI hierarchy
36
+ - It only creates: a document already at the URI is left as it is and the operation fails, unless
37
+ if_absent is true, which accepts it as the result (like mkdir -p). To replace a document, PUT it."""
30
38
 
31
39
  @classmethod
32
40
  def inputSchema(cls) -> dict:
@@ -40,13 +48,17 @@ class CreateItem(PUT):
40
48
  "title": {"type": "string", "description": "The title of the item"},
41
49
  "slug": {
42
50
  "type": "string",
43
- "description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.",
51
+ "description": "URL path segment (optional). Defaults to a random UUID.",
44
52
  },
45
53
  "description": {"type": "string", "description": "Description (optional)"},
46
54
  "primary_topic": {
47
55
  "type": "string",
48
56
  "description": "URI reference of what the item is about, resolved against its URI (optional), e.g. #this.",
49
57
  },
58
+ "if_absent": {
59
+ "type": "boolean",
60
+ "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.",
61
+ },
50
62
  },
51
63
  "required": ["container", "title"],
52
64
  }
@@ -58,6 +70,7 @@ class CreateItem(PUT):
58
70
  slug: Optional[Literal] = None,
59
71
  description: Optional[Literal] = None,
60
72
  primary_topic: Optional[Literal] = None,
73
+ if_absent: Optional[Literal] = None,
61
74
  ) -> Result:
62
75
  """Pure function: create item with RDFLib terms"""
63
76
  if not isinstance(container_uri, URIRef):
@@ -82,6 +95,8 @@ class CreateItem(PUT):
82
95
  f"CreateItem.execute expects primary_topic to be Literal or None, got {type(primary_topic)}"
83
96
  )
84
97
 
98
+ if_absent = if_absent_flag(if_absent, "CreateItem.execute")
99
+
85
100
  # URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that
86
101
  # leaving it out never lands on an existing document; deriving it from the title did, on a repeated title
87
102
  slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="")
@@ -118,8 +133,7 @@ class CreateItem(PUT):
118
133
  # Convert the document to a Graph and call parent PUT operation
119
134
  graph = self.to_graph(data, base=url)
120
135
 
121
- # Call parent PUT execute method
122
- return super().execute(URIRef(url), graph)
136
+ return create(self, URIRef(url), graph, if_absent)
123
137
 
124
138
  def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
125
139
  """JSON execution: process arguments with strict type checking"""
@@ -180,7 +194,14 @@ class CreateItem(PUT):
180
194
  f"ldh-CreateItem operation expects 'primary_topic' to be Literal, got {type(primary_topic)}"
181
195
  )
182
196
 
183
- return self.execute(container_uri, title, slug, description, primary_topic)
197
+ # Process optional if_absent
198
+ if_absent = None
199
+ if "if_absent" in arguments:
200
+ if_absent = Operation.json_to_rdflib(Operation.process_json(
201
+ self.settings, arguments["if_absent"], self.context, variable_stack
202
+ ))
203
+
204
+ return self.execute(container_uri, title, slug, description, primary_topic, if_absent)
184
205
 
185
206
  def mcp_run(self, arguments: dict, context: Any = None) -> Any:
186
207
  """MCP execution: plain args → plain results"""
@@ -202,12 +223,16 @@ class CreateItem(PUT):
202
223
  if "primary_topic" in arguments
203
224
  else None
204
225
  )
205
- result = self.execute(container_uri, title, slug, description, primary_topic)
226
+ if_absent = Literal(bool(arguments["if_absent"])) if "if_absent" in arguments else None
227
+ result = self.execute(container_uri, title, slug, description, primary_topic, if_absent)
206
228
 
207
- # Extract URL from PUT result
229
+ # Extract URL from PUT result; a 412 is the existing document if_absent accepted
208
230
  url_binding = result.bindings[0]["url"]
231
+ created = int(result.bindings[0]["status"]) != 412
209
232
  return [
210
233
  types.TextContent(
211
- type="text", text=f"Item created successfully at: {url_binding}"
234
+ type="text",
235
+ text=f"Item created successfully at: {url_binding}" if created
236
+ else f"A document already exists at {url_binding}; left as it is",
212
237
  )
213
238
  ]
@@ -560,9 +560,9 @@ The classes, datatype properties and object properties of the data at a SPARQL e
560
560
 
561
561
  These write to a LinkedDataHub instance. Every write returns the one-row `status`/`url` result. A document's **content** (what its page shows) is its sequence of blocks: only `ldh-AddXHTMLBlock` and `ldh-AddObjectBlock` add to it. The other `ldh-Add*` operations write a resource *into* the document (a query, a view, a chart, a service, a file) without showing it; to show it, place it with `ldh-AddObjectBlock`, its URI as `value` — so give it a `fragment`.
562
562
 
563
- ## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, children_view?: Literal, primary_topic?: Literal) → Result
563
+ ## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, children_view?: Literal, primary_topic?: Literal, if_absent?: Literal) → Result
564
564
 
565
- Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block lists its children through the shared `ldh:ChildrenView`, unless `children_view` is `false` — a JSON boolean, never the string `"false"`. To list them in another layout, pass `false`, add a view of `https://w3id.org/atomgraph/linkeddatahub#SelectChildren` in that mode to the container with `ldh-AddView` (with a `fragment`), and place it with `ldh-AddObjectBlock`. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it).
565
+ Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. It only creates: a document that already exists at that URL is left as it is and the operation fails, unless `if_absent` is `true` (a JSON boolean), which takes the existing document as the result and writes nothing, like `mkdir -p`. Pass it when the program means "make sure it exists"; to replace a document, `PUT` it. Its first content block lists its children through the shared `ldh:ChildrenView`, unless `children_view` is `false` — a JSON boolean, never the string `"false"`. To list them in another layout, pass `false`, add a view of `https://w3id.org/atomgraph/linkeddatahub#SelectChildren` in that mode to the container with `ldh-AddView` (with a `fragment`), and place it with `ldh-AddObjectBlock`. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it).
566
566
 
567
567
  ### Example JSON
568
568
  ```json
@@ -578,9 +578,9 @@ A container whose children are listed in another layout, here on a map:
578
578
  ]
579
579
  ```
580
580
 
581
- ## ldh-CreateItem(container: URI, title: Literal, slug?: Literal, description?: Literal, primary_topic?: Literal) → Result
581
+ ## ldh-CreateItem(container: URI, title: Literal, slug?: Literal, description?: Literal, primary_topic?: Literal, if_absent?: Literal) → Result
582
582
 
583
- Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). A document that already exists at that URL is replaced. `primary_topic` is what the item is about, a URI reference resolved against its URL (`#this` for a resource described in it).
583
+ Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). It only creates: a document that already exists at that URL is left as it is and the operation fails, unless `if_absent` is `true`, which takes the existing document as the result and writes nothing. To replace a document, `PUT` it. `primary_topic` is what the item is about, a URI reference resolved against its URL (`#this` for a resource described in it).
584
584
 
585
585
  ### Example JSON
586
586
  ```json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-algebra
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Composable RDF operations in JSON
5
5
  Author-email: Martynas Jusevicius <martynas@atomgraph.com>
6
6
  License-Expression: Apache-2.0
@@ -52,6 +52,7 @@ src/web_algebra/operations/linkeddatahub/add_generic_service.py
52
52
  src/web_algebra/operations/linkeddatahub/add_result_set_chart.py
53
53
  src/web_algebra/operations/linkeddatahub/add_select.py
54
54
  src/web_algebra/operations/linkeddatahub/add_view.py
55
+ src/web_algebra/operations/linkeddatahub/create.py
55
56
  src/web_algebra/operations/linkeddatahub/create_container.py
56
57
  src/web_algebra/operations/linkeddatahub/create_item.py
57
58
  src/web_algebra/operations/linkeddatahub/fragments.py
@@ -37,14 +37,14 @@ def test_the_worst_of_several_kinds_is_the_most_destructive():
37
37
  assert sorted([DESTRUCTIVE, READ, WRITE]) == [READ, WRITE, DESTRUCTIVE]
38
38
 
39
39
 
40
- def test_an_upsert_is_destructive_and_what_extends_it_is_too():
40
+ def test_an_upsert_is_destructive_but_a_create_is_not():
41
41
  # PUT replaces the whole document at a URL that already holds one;
42
- # ldh-CreateContainer and ldh-CreateItem PUT to parent + slug (or title),
43
- # so on an existing URL they replace it as well
42
+ # ldh-CreateContainer and ldh-CreateItem PUT with If-None-Match: *, so on
43
+ # an existing URL they are refused and remove nothing
44
44
  from web_algebra.operations.linked_data.put import PUT
45
45
  from web_algebra.operations.linkeddatahub.create_container import CreateContainer
46
46
  from web_algebra.operations.linkeddatahub.create_item import CreateItem
47
47
 
48
48
  assert PUT.kind is DESTRUCTIVE
49
- assert CreateContainer.kind is DESTRUCTIVE
50
- assert CreateItem.kind is DESTRUCTIVE
49
+ assert CreateContainer.kind is WRITE
50
+ assert CreateItem.kind is WRITE
File without changes
File without changes
File without changes