diff --git a/AGENTS.md b/AGENTS.md index 4c65fcb..1a11a56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,11 @@ docs, or a commit — use the variables. - `ldh` — needs a LinkedDataHub v6 instance and the certificate variables. The examples run against `https://localhost:4443/` (`LDH_BASE` selects another) and remove what they wrote unless `WA_KEEP_EXAMPLES=1`. + `tests/test_ldh_cli_parity.py` also needs `LDH_CLI`, the launcher of + LinkedDataHub's CLI (`cli/bin/ldh` of a checkout built with `mvn package`): + it runs every `ldh-*` operation and the CLI command for the same inputs and + requires the two documents to be the same graph. Run it after changing an + `ldh-*` operation, against the LinkedDataHub version you target. - `network`, `sparql` — need live HTTP or SPARQL endpoints. - `unit` and `integration` are applied automatically to `tests/unit/` and `tests/integration/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fc878..e78208c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ many are breaking. ### Added +- **The `ldh-*` operations take the LinkedDataHub CLI's options**, and write + them as it does: `ldh-CreateContainer` `children_view` and `primary_topic`; + `ldh-CreateItem` `description` and `primary_topic`; `ldh-AddObjectBlock` + `graph`; several series for `ldh-AddResultSetChart`; and + `ldh-AddView` `show_when_empty`. A view's `mode` may also be `ac:TableMode` + or `ac:ChartMode`. + `tests/test_ldh_cli_parity.py` runs each operation against the CLI on a live + LinkedDataHub and requires the same document. - **[formal-semantics.md](formal-semantics.md)**: abstract syntax, value domains, big-step evaluation rules, effects and ordering, the error table, the full catalog with an `Abstract:` signature per operation, and the @@ -134,6 +142,12 @@ many are breaking. model is given, is rewritten and checked by `tests/test_system_prompt.py` against each operation's schema and the spec: every example is well-formed, runs, and shows its actual result. ([#36](https://github.com/AtomGraph/Web-Algebra/pull/36)) +- **The model is `gpt-5.5`** by default, from `gpt-4o-mini`, for both the + interactive planner and `SPARQLString`, and `OPENAI_MODEL` overrides it; the + planner used to ignore the setting. The planner no longer asks for JSON mode, + which admits only an object: a plan of several steps written as an array + came back as its first step alone. A reply fenced as a Markdown code block + is read as the plan inside the fence. - **`ldh-*` titles and descriptions** may carry a language tag, and keep it. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) - **A `fragment`** may be given with or without its `#`. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) diff --git a/formal-semantics.md b/formal-semantics.md index 43f8898..fde19ff 100644 --- a/formal-semantics.md +++ b/formal-semantics.md @@ -1197,16 +1197,16 @@ 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` | -| `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal` | +| `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-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` | -| `ldh-AddResultSetChart` | `url: URI · query: URI · title: Literal · chart_type: URI · category_var_name: Literal · series_var_name: Literal · description/fragment: Maybe Literal` | +| `ldh-AddResultSetChart` | `url: URI · query: URI · title: Literal · chart_type: URI · category_var_name: Literal · series_var_name: Literal + Sequence Literal · description/fragment: Maybe Literal` | | `ldh-AddSelect` | `url: URI · query: Literal · title: Literal · description/fragment: Maybe Literal · service: Maybe URI` | | `ldh-AddConstruct` | `url: URI · query: Literal · title: Literal · description/fragment: Maybe Literal · service: Maybe URI` | -| `ldh-AddView` | `url: URI · query: URI · title: Literal · description/fragment: Maybe Literal · mode: Maybe URI` | -| `ldh-AddObjectBlock` | `url: URI · value: URI · title/description/fragment: Maybe Literal · mode: Maybe URI` | +| `ldh-AddView` | `url: URI · query: URI · title: Literal · description/fragment: Maybe Literal · mode: Maybe URI · show_when_empty: Maybe Literal (xsd:boolean)` | +| `ldh-AddObjectBlock` | `url: URI · value: URI · title/description/fragment: Maybe Literal · mode: Maybe URI · graph: Maybe URI` | | `ldh-AddXHTMLBlock` | `url: URI · value: Literal (XHTML) · title/description/fragment: Maybe Literal` | | `ldh-RemoveBlock` | `url: URI · block: Maybe URI` | | `ldh-GenerateOntologyViews` *(pure)* | `ontology: Graph · base_uri: URI · service_uri: URI` | @@ -1218,6 +1218,24 @@ 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. +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: + +- `ldh-CreateContainer` gives the container its first content block, + `rdf:_1`, an `ldh:Object` of `ldh:ChildrenView`, unless `children_view` is + false: then it has no content, which `ldh-AddObjectBlock` and + `ldh-AddXHTMLBlock` append as to any document. +- `primary_topic` (`ldh-CreateContainer`, `ldh-CreateItem`) is a URI + reference resolved against the document's URI (RFC 3986), written as the + document's one `foaf:primaryTopic` — `#this` names the conventional + fragment. +- `ldh-AddObjectBlock`'s `graph` is the block's `ldh:graph`: the document + that describes `value`, read in its place. +- `ldh-AddResultSetChart` writes one `ldh:seriesVarName` per name of + `series_var_name`, a name or a sequence of names. +- `ldh-AddView`'s `show_when_empty` is the view's `ldh:showWhenEmpty` + (LinkedDataHub shows an empty view unless it is `false`). + A `fragment` names the resource an operation creates in the document, `url#fragment`; it is given with or without the leading `#`. Without one the resource is a blank node. diff --git a/src/web_algebra/main.py b/src/web_algebra/main.py index bdd4cea..1ec68fc 100644 --- a/src/web_algebra/main.py +++ b/src/web_algebra/main.py @@ -3,6 +3,7 @@ import os import sys import json +import re from types import ModuleType from typing import List, Type, Optional import pkgutil @@ -27,7 +28,7 @@ class LinkedDataHubSettings(BaseSettings): cert_pem_path: Optional[str] = None cert_password: Optional[str] = None openai_client: Optional[OpenAI] = None - openai_model: str = "gpt-4o-mini" + openai_model: str = "gpt-5.5" def list_operation_subclasses( @@ -57,6 +58,20 @@ def register(classes: List[Type[Operation]]): Operation.register(cls) +# a Markdown code fence around the whole reply, which a model may add without JSON mode +FENCE = re.compile(r"\A```[\w-]*\s*\n(.*)\n\s*```\Z", re.S) + + +def parse_plan(reply: str): + """The plan in a model's reply: its JSON, fenced or not, an object or an array of steps.""" + text = reply.strip() + fenced = FENCE.match(text) + plan = json.loads(fenced.group(1) if fenced else text) + if not isinstance(plan, (dict, list)): + raise ValueError(f"A plan is a JSON object or array, got {type(plan).__name__}") + return plan + + def main(settings: BaseSettings, json_data: Optional[str]): register(list_operation_subclasses(web_algebra.operations, Operation)) @@ -83,7 +98,7 @@ def main(settings: BaseSettings, json_data: Optional[str]): sys.exit("Error: Environment variable OPENAI_API_KEY is not set.") client = OpenAI(api_key=api_key) - model = "gpt-4o-mini" + model = settings.openai_model # Load prompts # the prompts are package data, found wherever the package is installed @@ -105,14 +120,13 @@ def main(settings: BaseSettings, json_data: Optional[str]): {"role": "user", "content": user_prompt}, ) + # no JSON mode: it admits only an object, and a plan may be an array of steps chat_completion = client.chat.completions.create( model=model, messages=messages, - response_format={"type": "json_object"}, ) - reply_json = json.loads(chat_completion.choices[0].message.content) - assert isinstance(reply_json, dict) + reply_json = parse_plan(chat_completion.choices[0].message.content) logging.info("Generated response: %s", reply_json) # Execute the generated operation diff --git a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py index 214ca9f..7470153 100644 --- a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py +++ b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Sequence, Union import logging from rdflib import Literal, URIRef from rdflib.namespace import XSD @@ -67,8 +67,9 @@ def inputSchema(cls) -> dict: "description": "Name of the SPARQL variable used as category (without leading '?').", }, "series_var_name": { - "type": "string", - "description": "Name of the SPARQL variable used as series/value (without leading '?').", + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "Name of the SPARQL variable used as series/value (without leading '?'), or an array of names for several series.", }, "description": { "type": "string", @@ -129,7 +130,11 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - series_var_name_data = Operation.process_json( self.settings, arguments["series_var_name"], self.context, variable_stack ) - series_var_name_literal = self.to_string_literal(series_var_name_data) + # a name, or a sequence of names - one series each + if isinstance(series_var_name_data, list): + series_var_name_literal = [self.to_string_literal(name) for name in series_var_name_data] + else: + series_var_name_literal = self.to_string_literal(series_var_name_data) # Process optional arguments description_literal = None @@ -164,7 +169,7 @@ def execute( title: Literal, chart_type: URIRef, category_var_name: Literal, - series_var_name: Literal, + series_var_name: Union[Literal, Sequence[Literal]], description: Optional[Literal] = None, fragment: Optional[Literal] = None, ) -> Any: @@ -191,11 +196,10 @@ def execute( raise TypeError( f"AddResultSetChart.execute expects category_var_name to be string Literal, got {type(category_var_name)}" ) - if ( - not Operation.is_string_literal(series_var_name) - ): + series_var_names = series_var_name if isinstance(series_var_name, (list, tuple)) else [series_var_name] + if not series_var_names or not all(Operation.is_string_literal(name) for name in series_var_names): raise TypeError( - f"AddResultSetChart.execute expects series_var_name to be string Literal, got {type(series_var_name)}" + f"AddResultSetChart.execute expects series_var_name to be a string Literal or a non-empty sequence of them, got {series_var_name!r}" ) if description is not None and ( not Operation.is_string_compatible(description) @@ -215,7 +219,7 @@ def execute( title_str = Operation.jsonld_text(title) chart_type_str = str(chart_type) category_var_name_str = str(category_var_name) - series_var_name_str = str(series_var_name) + series_var_name_strs = [str(name) for name in series_var_names] description_str = Operation.jsonld_text(description) if description else None fragment_str = fragment_id(fragment) @@ -244,7 +248,7 @@ def execute( "spin:query": {"@id": query_str}, "ldh:chartType": {"@id": chart_type_str}, "ldh:categoryVarName": category_var_name_str, - "ldh:seriesVarName": series_var_name_str, + "ldh:seriesVarName": series_var_name_strs if len(series_var_name_strs) > 1 else series_var_name_strs[0], } # Add optional properties - matching shell script conditional logic @@ -267,7 +271,12 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: title = Literal(arguments["title"], datatype=XSD.string) chart_type = URIRef(arguments["chart_type"]) category_var_name = Literal(arguments["category_var_name"], datatype=XSD.string) - series_var_name = Literal(arguments["series_var_name"], datatype=XSD.string) + names = arguments["series_var_name"] + series_var_name = ( + [Literal(name, datatype=XSD.string) for name in names] + if isinstance(names, list) + else Literal(names, datatype=XSD.string) + ) description = None if "description" in arguments: diff --git a/src/web_algebra/operations/linkeddatahub/add_view.py b/src/web_algebra/operations/linkeddatahub/add_view.py index e99004e..2316f21 100644 --- a/src/web_algebra/operations/linkeddatahub/add_view.py +++ b/src/web_algebra/operations/linkeddatahub/add_view.py @@ -66,15 +66,21 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the display mode for the view.", + "description": "Optional URI of the layout the view shows its results in (a view mode, not an object block's mode). Defaults to ListMode.", "enum": [ - "https://w3id.org/atomgraph/client#ReadMode", "https://w3id.org/atomgraph/client#ListMode", + "https://w3id.org/atomgraph/client#TableMode", "https://w3id.org/atomgraph/client#GridMode", + "https://w3id.org/atomgraph/client#ReadMode", "https://w3id.org/atomgraph/client#GraphMode", "https://w3id.org/atomgraph/client#MapMode", + "https://w3id.org/atomgraph/client#ChartMode", ], }, + "show_when_empty": { + "type": "boolean", + "description": "Optional: whether the view is shown when its query returns no results (ldh:showWhenEmpty). LinkedDataHub shows it unless this is false.", + }, }, "required": ["url", "query", "title"], } @@ -129,6 +135,12 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - ) mode_uri = mode_data + show_when_empty = None + if "show_when_empty" in arguments: + show_when_empty = Operation.json_to_rdflib(Operation.process_json( + self.settings, arguments["show_when_empty"], self.context, variable_stack + )) + return self.execute( url_data, query_data, @@ -136,6 +148,7 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - description_literal, fragment_literal, mode_uri, + show_when_empty, ) def execute( @@ -146,6 +159,7 @@ def execute( description: Optional[Literal] = None, fragment: Optional[Literal] = None, mode: Optional[URIRef] = None, + show_when_empty: Optional[Literal] = None, ) -> Any: """Pure function: create SPARQL result set view with RDFLib terms""" if not isinstance(url, URIRef): @@ -177,6 +191,13 @@ def execute( f"AddView.execute expects mode to be URIRef, got {type(mode)}" ) + if show_when_empty is not None and not ( + isinstance(show_when_empty, Literal) and show_when_empty.datatype == XSD.boolean + ): + raise TypeError( + f"AddView.execute expects show_when_empty to be an xsd:boolean Literal, got {show_when_empty!r}" + ) + url_str = str(url) query_str = str(query) title_str = Operation.jsonld_text(title) @@ -218,6 +239,9 @@ def execute( data["@context"]["ac"] = "https://w3id.org/atomgraph/client#" data["ac:mode"] = {"@id": mode_str} + if show_when_empty is not None: + data["ldh:showWhenEmpty"] = show_when_empty.toPython() + logging.info(f"Posting View with JSON-LD data: {data}") # Convert the JSON-LD content to a Graph and POST to the target URI @@ -245,8 +269,12 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: if "mode" in arguments: mode = URIRef(arguments["mode"]) + show_when_empty = ( + Literal(bool(arguments["show_when_empty"])) if "show_when_empty" in arguments else None + ) + # Call pure function - result = self.execute(url, query, title, description, fragment, mode) + result = self.execute(url, query, title, description, fragment, mode, show_when_empty) # Return status for MCP response status_binding = result.bindings[0]["status"] diff --git a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py index d49765c..28d3b32 100644 --- a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py +++ b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py @@ -65,15 +65,19 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the block mode. Defaults to ReadMode if not specified.", + "description": "Optional URI of the mode the block shows its resource in (an object mode, not a view's layout). Defaults to ReadMode.", "enum": [ + "https://w3id.org/atomgraph/client#ReadMode", "https://w3id.org/atomgraph/client#GraphMode", "https://w3id.org/atomgraph/client#MapMode", "https://w3id.org/atomgraph/client#ChartMode", - "https://w3id.org/atomgraph/client#ReadMode", ], "default": "https://w3id.org/atomgraph/client#ReadMode", }, + "graph": { + "type": "string", + "description": "Optional URI of the document that describes the value, read in place of dereferencing the value's URI (ldh:graph).", + }, }, "required": ["url", "value"], } @@ -130,6 +134,17 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - ) mode_uri = mode_data + graph_uri = None + if "graph" in arguments: + graph_data = Operation.process_json( + self.settings, arguments["graph"], self.context, variable_stack + ) + if not isinstance(graph_data, URIRef): + raise TypeError( + f"AddObjectBlock operation expects 'graph' to be URIRef, got {type(graph_data)}" + ) + graph_uri = graph_data + return self.execute( url_data, value_data, @@ -137,6 +152,7 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - description_literal, fragment_literal, mode_uri, + graph_uri, ) def execute( @@ -147,6 +163,7 @@ def execute( description: Literal = None, fragment: Literal = None, mode: URIRef = None, + graph: URIRef = None, ) -> Any: """Pure function: create object block with RDFLib terms""" if not isinstance(url, URIRef): @@ -179,6 +196,10 @@ def execute( raise TypeError( f"AddObjectBlock.execute expects mode to be URIRef, got {type(mode)}" ) + if graph is not None and not isinstance(graph, URIRef): + raise TypeError( + f"AddObjectBlock.execute expects graph to be URIRef, got {type(graph)}" + ) url_str = str(url) value_str = str(value) @@ -195,12 +216,12 @@ def execute( # Step 1: Get current document to find next sequence number get_op = GET(settings=self.settings, context=self.context) - graph = get_op.execute(url) + document = get_op.execute(url) # Convert Graph to JSON-LD for processing import json - jsonld_str = graph.serialize(format="json-ld") + jsonld_str = document.serialize(format="json-ld") doc = json.loads(jsonld_str) # Step 2: Extract sequence numbers from rdf:_N properties in JSON-LD list @@ -267,14 +288,17 @@ def execute( if mode_str: block["ac:mode"] = {"@id": mode_str} + if graph is not None: + # the document describing the value, read in its place (ldh:graph) + block["ldh:graph"] = {"@id": str(graph)} + # Add object block to the data structure data[sequence_property] = block logging.info(f"Posting object block with JSON-LD data: {data}") # 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) + return super().execute(url, self.to_graph(data, base=url_str)) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -300,8 +324,10 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: if "mode" in arguments: mode = URIRef(arguments["mode"]) + graph = URIRef(arguments["graph"]) if "graph" in arguments else None + # Call pure function - result = self.execute(url, value, title, description, fragment, mode) + result = self.execute(url, value, title, description, fragment, mode, graph) # Return status for MCP response status_binding = result.bindings[0]["status"] diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index c39b8b9..f239769 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -6,6 +6,7 @@ from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation +from web_algebra.rfc3986 import resolve from web_algebra.operations.linked_data.put import PUT from rdflib.query import Result @@ -48,6 +49,14 @@ def inputSchema(cls) -> dict: "type": "string", "description": "Description (optional)", }, + "children_view": { + "type": "boolean", + "description": "Optional: whether the container's first content block lists its children (the shared ldh:ChildrenView). It does unless this is false; the content is then added with ldh-AddObjectBlock and ldh-AddXHTMLBlock, as to any document.", + }, + "primary_topic": { + "type": "string", + "description": "URI reference of what the container is about, resolved against its URI (optional), e.g. #this.", + }, }, "required": ["parent", "title"], } @@ -58,6 +67,8 @@ def execute( title: Literal, slug: Optional[Literal] = None, description: Optional[Literal] = None, + children_view: Optional[Literal] = None, + primary_topic: Optional[Literal] = None, ) -> Result: """Pure function: create container with RDFLib terms""" if not isinstance(parent_uri, URIRef): @@ -76,6 +87,17 @@ def execute( raise TypeError( f"CreateContainer.execute expects description to be Literal or None, got {type(description)}" ) + if children_view is not None and not ( + isinstance(children_view, Literal) and children_view.datatype == XSD.boolean + ): + raise TypeError( + f"CreateContainer.execute expects children_view to be an xsd:boolean Literal, got {children_view!r}" + ) + if primary_topic is not None and not isinstance(primary_topic, Literal): + raise TypeError( + f"CreateContainer.execute expects primary_topic to be Literal or None, got {type(primary_topic)}" + ) + # 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="") @@ -99,15 +121,22 @@ def execute( "dct": "http://purl.org/dc/terms/", "sioc": "http://rdfs.org/sioc/ns#", "xsd": "http://www.w3.org/2001/XMLSchema#", + "foaf": "http://xmlns.com/foaf/0.1/", }, "@id": url, "@type": "dh:Container", "dct:title": Operation.jsonld_text(title), - "rdf:_1": {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}}, } + # the first content block, as the LinkedDataHub CLI writes it: the children + # view, unless children_view is false (formal-semantics.md Appendix A) + if children_view is None or children_view.toPython(): + data["rdf:_1"] = {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}} if description: data["dct:description"] = Operation.jsonld_text(description) + if primary_topic is not None: + # a URI reference resolved against the document (RFC 3986) + data["foaf:primaryTopic"] = {"@id": resolve(url, str(primary_topic))} # Convert the document to a Graph and call parent PUT operation graph = self.to_graph(data, base=url) @@ -161,7 +190,27 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: f"ldh-CreateContainer operation expects 'description' to be Literal, got {type(description)}" ) - return self.execute(parent_uri, title, slug, description) + + # Process optional children_view + children_view = None + if "children_view" in arguments: + children_view = Operation.json_to_rdflib(Operation.process_json( + self.settings, arguments["children_view"], self.context, variable_stack + )) + + # Process optional primary_topic + primary_topic = None + if "primary_topic" in arguments: + primary_topic_data = Operation.process_json( + self.settings, arguments["primary_topic"], self.context, variable_stack + ) + primary_topic = Operation.json_to_rdflib(primary_topic_data) + if not isinstance(primary_topic, Literal): + raise TypeError( + 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) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -178,7 +227,15 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: else None ) - result = self.execute(parent_uri, title, slug, description) + children_view = ( + Literal(bool(arguments["children_view"])) if "children_view" in arguments else None + ) + primary_topic = ( + Literal(arguments["primary_topic"], datatype=XSD.string) + if "primary_topic" in arguments + else None + ) + result = self.execute(parent_uri, title, slug, description, children_view, primary_topic) # Extract URL from PUT result url_binding = result.bindings[0]["url"] diff --git a/src/web_algebra/operations/linkeddatahub/create_item.py b/src/web_algebra/operations/linkeddatahub/create_item.py index 940d86d..974d015 100644 --- a/src/web_algebra/operations/linkeddatahub/create_item.py +++ b/src/web_algebra/operations/linkeddatahub/create_item.py @@ -6,6 +6,7 @@ from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation +from web_algebra.rfc3986 import resolve from web_algebra.operations.linked_data.put import PUT from rdflib.query import Result @@ -41,6 +42,11 @@ def inputSchema(cls) -> dict: "type": "string", "description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.", }, + "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.", + }, }, "required": ["container", "title"], } @@ -50,6 +56,8 @@ def execute( container_uri: URIRef, title: Literal, slug: Optional[Literal] = None, + description: Optional[Literal] = None, + primary_topic: Optional[Literal] = None, ) -> Result: """Pure function: create item with RDFLib terms""" if not isinstance(container_uri, URIRef): @@ -65,6 +73,15 @@ def execute( f"CreateItem.execute expects slug to be Literal or None, got {type(slug)}" ) + if description is not None and not Operation.is_string_compatible(description): + raise TypeError( + f"CreateItem.execute expects description to be a string Literal or None, got {type(description)}" + ) + if primary_topic is not None and not isinstance(primary_topic, Literal): + raise TypeError( + f"CreateItem.execute expects primary_topic to be Literal or None, got {type(primary_topic)}" + ) + # 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="") @@ -86,11 +103,17 @@ def execute( "dct": "http://purl.org/dc/terms/", "sioc": "http://rdfs.org/sioc/ns#", "xsd": "http://www.w3.org/2001/XMLSchema#", + "foaf": "http://xmlns.com/foaf/0.1/", }, "@id": url, "@type": "dh:Item", "dct:title": Operation.jsonld_text(title), } + if description: + data["dct:description"] = Operation.jsonld_text(description) + if primary_topic is not None: + # a URI reference resolved against the document (RFC 3986) + data["foaf:primaryTopic"] = {"@id": resolve(url, str(primary_topic))} # Convert the document to a Graph and call parent PUT operation graph = self.to_graph(data, base=url) @@ -132,7 +155,32 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: f"ldh-CreateItem operation expects 'slug' to be Literal, got {type(slug)}" ) - return self.execute(container_uri, title, slug) + + # Process optional description + description = None + if "description" in arguments: + description_data = Operation.process_json( + self.settings, arguments["description"], self.context, variable_stack + ) + description = Operation.json_to_rdflib(description_data) + if not isinstance(description, Literal): + raise TypeError( + f"ldh-CreateItem operation expects 'description' to be Literal, got {type(description)}" + ) + + # Process optional primary_topic + primary_topic = None + if "primary_topic" in arguments: + primary_topic_data = Operation.process_json( + self.settings, arguments["primary_topic"], self.context, variable_stack + ) + primary_topic = Operation.json_to_rdflib(primary_topic_data) + if not isinstance(primary_topic, Literal): + raise TypeError( + f"ldh-CreateItem operation expects 'primary_topic' to be Literal, got {type(primary_topic)}" + ) + + return self.execute(container_uri, title, slug, description, primary_topic) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -144,7 +192,17 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: else None ) - result = self.execute(container_uri, title, slug) + description = ( + Literal(arguments["description"], datatype=XSD.string) + if "description" in arguments + else None + ) + primary_topic = ( + Literal(arguments["primary_topic"], datatype=XSD.string) + if "primary_topic" in arguments + else None + ) + result = self.execute(container_uri, title, slug, description, primary_topic) # Extract URL from PUT result url_binding = result.bindings[0]["url"] diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index 9dc3c08..5190710 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -560,18 +560,27 @@ The classes, datatype properties and object properties of the data at a SPARQL e 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`. -## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal) → Result +## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, children_view?: Literal, primary_topic?: Literal) → Result -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. +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). ### Example JSON ```json {"@op": "ldh-CreateContainer", "args": {"parent": {"@id": "https://localhost:4443/"}, "title": "Danish cities", "slug": "denmark"}} ``` -## ldh-CreateItem(container: URI, title: Literal, slug?: Literal) → Result +A container whose children are listed in another layout, here on a map: +```json +[ + {"@op": "ldh-CreateContainer", "args": {"parent": {"@id": "https://localhost:4443/"}, "title": "Danish cities", "slug": "denmark", "children_view": false}}, + {"@op": "ldh-AddView", "args": {"url": {"@id": "https://localhost:4443/denmark/"}, "query": {"@id": "https://w3id.org/atomgraph/linkeddatahub#SelectChildren"}, "title": "Danish cities", "fragment": "cities", "mode": {"@id": "https://w3id.org/atomgraph/client#MapMode"}}}, + {"@op": "ldh-AddObjectBlock", "args": {"url": {"@id": "https://localhost:4443/denmark/"}, "value": {"@id": "https://localhost:4443/denmark/#cities"}}} +] +``` + +## ldh-CreateItem(container: URI, title: Literal, slug?: Literal, description?: Literal, primary_topic?: Literal) → Result -Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). A document that already exists at that URL is replaced. +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). ### Example JSON ```json @@ -627,9 +636,9 @@ Writes a stored SPARQL `CONSTRUCT` query (`sp:Construct`) into the document, as } ``` -## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI) → Result +## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI, show_when_empty?: Literal) → Result -Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. `mode` is a layout: `https://w3id.org/atomgraph/client#ReadMode`, `#ListMode`, `#GridMode`, `#GraphMode` or `#MapMode`. To show the view, place it with `ldh-AddObjectBlock`. +Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is the layout of the results, a view mode: `https://w3id.org/atomgraph/client#ListMode` (the default), `#TableMode`, `#GridMode`, `#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. `show_when_empty: false` (a JSON boolean, never the string `"false"`) hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. ### Example JSON ```json @@ -654,9 +663,9 @@ Writes a view of a stored `SELECT` query's results into the document. `query` is ] ``` -## ldh-AddResultSetChart(url: URI, query: URI, title: Literal, chart_type: URI, category_var_name: Literal, series_var_name: Literal, description?: Literal, fragment?: Literal) → Result +## ldh-AddResultSetChart(url: URI, query: URI, title: Literal, chart_type: URI, category_var_name: Literal, series_var_name: Literal + Sequence Literal, description?: Literal, fragment?: Literal) → Result -Writes a chart of a stored `SELECT` query's results into the document. `chart_type` is one of `https://w3id.org/atomgraph/client#Table`, `#BarChart`, `#LineChart`, `#ScatterChart`, `#Timeline`; the category and series are variable names of the query. To show the chart, place it with `ldh-AddObjectBlock`. +Writes a chart of a stored `SELECT` query's results into the document. `chart_type` is one of `https://w3id.org/atomgraph/client#Table`, `#BarChart`, `#LineChart`, `#ScatterChart`, `#Timeline`; the category and series are variable names of the query, and `series_var_name` may be an array of names for several series. To show the chart, place it with `ldh-AddObjectBlock`. ### Example JSON ```json @@ -707,9 +716,9 @@ Uploads a local file (an absolute path) and writes its description into the docu } ``` -## ldh-AddObjectBlock(url: URI, value: URI, title?: Literal, description?: Literal, fragment?: Literal, mode?: URI) → Result +## ldh-AddObjectBlock(url: URI, value: URI, title?: Literal, description?: Literal, fragment?: Literal, mode?: URI, graph?: URI) → Result -Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is `https://w3id.org/atomgraph/client#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. +Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is how the block shows its resource, an object mode: `https://w3id.org/atomgraph/client#ReadMode` (the default), `#GraphMode`, `#MapMode` or `#ChartMode`. It is not a view's layout: a view placed by the block keeps its own `mode`. `graph` is the document that describes `value`, read instead of dereferencing `value`'s URI. ### Example JSON ```json diff --git a/tests/test_ldh_cli_parity.py b/tests/test_ldh_cli_parity.py new file mode 100644 index 0000000..604e975 --- /dev/null +++ b/tests/test_ldh_cli_parity.py @@ -0,0 +1,231 @@ +"""The ldh-* operations write what LinkedDataHub's CLI writes (formal-semantics.md +Appendix A): each operation and the CLI command it corresponds to are run with +the same inputs, every option included, against a LinkedDataHub, and the two +documents must be the same graph. + +Live (marker `ldh`): `LDH_BASE` (default https://localhost:4443/) with the +certificate in CERT_PEM_PATH and CERT_PASSWORD, and `LDH_CLI`, the CLI's +launcher (`cli/bin/ldh` of a LinkedDataHub checkout built with `mvn package`). +Skipped when any is missing. Everything a run writes is deleted afterwards. + +Two things the server decides itself are set aside in the comparison: the +dct:created / dct:modified stamps, and the fragment IDs it mints for blank +nodes (`#id`), which are compared as blank nodes. +""" + +from __future__ import annotations + +import os +import re +import ssl +import subprocess +import urllib.error +import urllib.request +import uuid + +import pytest +from rdflib import BNode, Graph, URIRef +from rdflib.compare import graph_diff, to_isomorphic +from rdflib.namespace import DCTERMS + +from web_algebra.main import LinkedDataHubSettings +from web_algebra.operation import Operation + +pytestmark = pytest.mark.ldh + +BASE = os.getenv("LDH_BASE", "https://localhost:4443/") +CLI = os.getenv("LDH_CLI") +AC = "https://w3id.org/atomgraph/client#" +SKOLEM = re.compile(r"#id[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +XHTML = '

Hello

' + + +@pytest.fixture(scope="module") +def ldh(): + cert, password = os.getenv("CERT_PEM_PATH"), os.getenv("CERT_PASSWORD") + if not (cert and password): + pytest.skip("CERT_PEM_PATH and CERT_PASSWORD are required") + if not (CLI and os.access(CLI, os.X_OK)): + pytest.skip("LDH_CLI must name LinkedDataHub's CLI launcher") + settings = LinkedDataHubSettings(cert_pem_path=cert, cert_password=password) + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.load_cert_chain(cert, password=password) + opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context)) + env = {**os.environ, "LDH_CERT_FILE": cert, "LDH_CERT_PASSWORD": password} + run = BASE + "wa-cli-parity-" + uuid.uuid4().hex[:8] + "/" + state = {"settings": settings, "opener": opener, "env": env, "run": run} + yield state + _delete_under(opener, run) + + +def _delete_under(opener, prefix): + """Delete every document under `prefix`, deepest first, each with the + If-Match LinkedDataHub requires.""" + query = f'SELECT DISTINCT ?g WHERE {{ GRAPH ?g {{ ?s ?p ?o }} FILTER(STRSTARTS(STR(?g), "{prefix}")) }}' + request = urllib.request.Request( + BASE + "sparql?" + urllib.parse.urlencode({"query": query}), + headers={"Accept": "application/sparql-results+xml"}, + ) + with opener.open(request) as response: + graphs = re.findall(r"([^<]+)", response.read().decode()) + accept = {"Accept": "application/n-triples"} + for graph in sorted(set(graphs), key=lambda u: -u.count("/")): + try: + with opener.open(urllib.request.Request(graph, method="HEAD", headers=accept)) as head: + etag = head.headers["ETag"] + with opener.open(urllib.request.Request(graph, method="DELETE", headers={**accept, "If-Match": etag})): + pass + except urllib.error.HTTPError: + pass + + +def _wa(ldh, op, **args): + plan = {"@op": op, "args": {k: ({"@id": v[1:]} if isinstance(v, str) and v.startswith("@") else v) for k, v in args.items()}} + return Operation.process_json(ldh["settings"], plan) + + +def _cli(ldh, *argv) -> str: + done = subprocess.run([CLI, *argv], env=ldh["env"], capture_output=True, text=True, timeout=120) + assert done.returncode == 0, f"ldh {' '.join(argv[:2])}: {done.stderr.strip()[:500]}" + return done.stdout.strip() + + +def _url(result) -> str: + return str(list(result.bindings)[0]["url"]) + + +def _document(ldh, url) -> Graph: + request = urllib.request.Request(url, headers={"Accept": "application/n-triples"}) + with ldh["opener"].open(request) as response: + g = Graph().parse(data=response.read().decode(), format="nt") + normal = Graph() + + def term(t): + if isinstance(t, URIRef) and str(t).startswith(url): + if SKOLEM.search(str(t)): + return BNode(str(t)) + return URIRef("urn:doc/" + str(t)[len(url):]) + return t + + for s, p, o in g: + if p in (DCTERMS.created, DCTERMS.modified) and term(s) == URIRef("urn:doc/"): + continue + normal.add((term(s), p, term(o))) + return normal + + +def _same(ldh, ours, theirs): + a, b = _document(ldh, ours), _document(ldh, theirs) + _, only_ours, only_cli = graph_diff(to_isomorphic(a), to_isomorphic(b)) + lines = [f"ours: {t[0].n3()} {t[1].n3()} {t[2].n3()}" for t in only_ours] + lines += [f"CLI: {t[0].n3()} {t[1].n3()} {t[2].n3()}" for t in only_cli] + assert not lines, "\n".join(sorted(lines)) + + +@pytest.fixture(scope="module") +def parent(ldh): + """The run's container, holding every document the tests write.""" + return _url(_wa(ldh, "ldh-CreateContainer", parent="@" + BASE, title="CLI parity", + slug=ldh["run"][len(BASE):-1])) + + +@pytest.mark.parametrize( + "options, cli_options", + [ + ({}, []), + ({"description": "A container"}, ["--description", "A container"]), + ({"children_view": False}, ["--no-children-view"]), + ({"primary_topic": "#this"}, ["--primary-topic", "#this"]), + ], + ids=["plain", "description", "no-children-view", "primary-topic"], +) +def test_create_container(ldh, parent, options, cli_options): + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateContainer", parent="@" + parent, title="Container", slug=slug, **options)) + theirs = _cli(ldh, "create", "container", "--parent", parent, "--title", "Container", "--slug", slug + "-cli", *cli_options).split()[-1] + _same(ldh, ours, theirs) + + +@pytest.mark.parametrize( + "options, cli_options", + [ + ({}, []), + ({"description": "An item"}, ["--description", "An item"]), + ({"primary_topic": "#this"}, ["--primary-topic", "#this"]), + ], + ids=["plain", "description", "primary-topic"], +) +def test_create_item(ldh, parent, options, cli_options): + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateItem", container="@" + parent, title="Item", slug=slug, **options)) + theirs = _cli(ldh, "create", "item", "--container", parent, "--title", "Item", "--slug", slug + "-cli", *cli_options).split()[-1] + _same(ldh, ours, theirs) + + +@pytest.fixture(scope="module") +def pair(ldh, parent): + """An item written by the operations and one written by the CLI, which + the content tests below add the same resources to, in the same order.""" + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateItem", container="@" + parent, title="Content", slug=slug)) + theirs = _cli(ldh, "create", "item", "--container", parent, "--title", "Content", "--slug", slug + "-cli").split()[-1] + return ours, theirs + + +SELECT = "SELECT ?s ?o WHERE { ?s a ?o } LIMIT 10" +CONSTRUCT = "CONSTRUCT { ?s a ?o } WHERE { ?s a ?o } LIMIT 10" + +# (operation, its arguments, the CLI command and its options); `{doc}` is the item written to +CONTENT = [ + ("ldh-AddSelect", dict(query=SELECT, title="Q", description="Select", fragment="q", service="@" + BASE + "#service"), + ["add", "select", "--uri", "#q", "--title", "Q", "--description", "Select", "--query-file", "{select}", "--service", BASE + "#service"]), + ("ldh-AddConstruct", dict(query=CONSTRUCT, title="C", fragment="c"), + ["add", "construct", "--uri", "#c", "--title", "C", "--query-file", "{construct}"]), + ("ldh-AddView", dict(query="@{doc}#q", title="V", description="View", fragment="v", mode="@" + AC + "TableMode"), + ["add", "view", "--uri", "#v", "--title", "V", "--description", "View", "--query", "{doc}#q", "--mode", AC + "TableMode"]), + ("ldh-AddResultSetChart", dict(query="@{doc}#q", title="Ch", chart_type="@" + AC + "BarChart", category_var_name="s", + series_var_name=["o", "p"], description="Chart", fragment="ch"), + ["add", "result-set-chart", "--uri", "#ch", "--title", "Ch", "--query", "{doc}#q", "--chart-type", AC + "BarChart", + "--category-var-name", "s", "--series-var-name", "o", "--series-var-name", "p", "--description", "Chart"]), + ("ldh-AddObjectBlock", dict(value="@{doc}#v", title="OB", description="Object", fragment="ob", mode="@" + AC + "GraphMode", + graph="@{doc}"), + ["add", "object-block", "--uri", "#ob", "--title", "OB", "--description", "Object", "--value", "{doc}#v", + "--mode", AC + "GraphMode", "--graph", "{doc}"]), + ("ldh-AddXHTMLBlock", dict(value=XHTML, title="XB", description="XHTML", fragment="xb"), + ["add", "xhtml-block", "--uri", "#xb", "--title", "XB", "--description", "XHTML", "--value", XHTML]), + ("ldh-AddGenericService", dict(endpoint="@https://example.org/sparql", title="Svc", description="Service", fragment="svc", + graph_store="@https://example.org/service", auth_user="user", auth_pwd="secret"), + ["add", "generic-service", "--uri", "#svc", "--title", "Svc", "--description", "Service", + "--endpoint", "https://example.org/sparql", "--graph-store", "https://example.org/service", + "--auth-user", "user", "--auth-pwd", "secret"]), +] + + +def test_content(ldh, pair, tmp_path): + """Each resource added in turn; the documents stay the same at every step.""" + ours, theirs = pair + files = {"select": tmp_path / "select.rq", "construct": tmp_path / "construct.rq"} + files["select"].write_text(SELECT) + files["construct"].write_text(CONSTRUCT) + for op, args, argv in CONTENT: + _wa(ldh, op, url="@" + ours, **{k: (v.replace("{doc}", ours) if isinstance(v, str) else v) for k, v in args.items()}) + _cli(ldh, *[a.format(doc=theirs, **files) if "{" in a else a for a in argv], theirs) + _same(ldh, ours, theirs) + + _wa(ldh, "ldh-RemoveBlock", url="@" + ours, block="@" + ours + "#xb") + _cli(ldh, "remove", "block", "--block", theirs + "#xb", theirs) + _same(ldh, ours, theirs) + + +def test_add_file(ldh, pair, tmp_path): + ours, theirs = pair + upload = tmp_path / "upload.txt" + upload.write_text("Web-Algebra CLI parity " + uuid.uuid4().hex + "\n") + file_ours = _url(_wa(ldh, "ldh-AddFile", url="@" + ours, file=str(upload), title="Upload", description="A file", + content_type="text/plain")) + file_theirs = _cli(ldh, "add", "file", "--file", str(upload), "--title", "Upload", "--description", "A file", + "--content-type", "text/plain", theirs).split()[-1] + assert file_ours == file_theirs + _same(ldh, ours, theirs) diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..8727f33 --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,46 @@ +"""The interactive planner's reading of a model reply (web_algebra.main.parse_plan): +the plan is the reply's JSON, an object or an array of steps, whether or not the +model fenced it as a Markdown code block - the planner asks for no JSON mode, +which admits only an object.""" + +from __future__ import annotations + +import pytest + +from web_algebra.main import parse_plan + +STEP = {"@op": "Str", "args": {"input": "a"}} + + +@pytest.mark.parametrize( + "reply", + [ + '{"@op": "Str", "args": {"input": "a"}}', + '```json\n{"@op": "Str", "args": {"input": "a"}}\n```', + '```\n{"@op": "Str", "args": {"input": "a"}}\n```', + ' \n```json\n{"@op": "Str", "args": {"input": "a"}}\n```\n', + ], + ids=["bare", "json-fence", "plain-fence", "padded"], +) +def test_an_object_plan(reply): + assert parse_plan(reply) == STEP + + +@pytest.mark.parametrize( + "reply", + ['[{"@op": "Str", "args": {"input": "a"}}]', '```json\n[\n {"@op": "Str", "args": {"input": "a"}}\n]\n```'], + ids=["bare", "fenced"], +) +def test_an_array_of_steps_is_a_plan(reply): + assert parse_plan(reply) == [STEP] + + +@pytest.mark.parametrize("reply", ['"a"', "1", "null"]) +def test_a_scalar_is_not_a_plan(reply): + with pytest.raises(ValueError): + parse_plan(reply) + + +def test_prose_around_the_fence_is_not_stripped(): + with pytest.raises(ValueError): + parse_plan('Here is the plan:\n```json\n{"@op": "Str", "args": {"input": "a"}}\n```') diff --git a/tests/unit/test_ldh_cli_options.py b/tests/unit/test_ldh_cli_options.py new file mode 100644 index 0000000..3c6586a --- /dev/null +++ b/tests/unit/test_ldh_cli_options.py @@ -0,0 +1,181 @@ +"""Spec: formal-semantics.md Appendix A - the options the ldh-* operations share +with LinkedDataHub's CLI, written as the CLI writes them: + +- ldh-CreateContainer's first content block, rdf:_1: an ldh:Object of + ldh:ChildrenView, unless `children_view` is false, when there is none. +- `primary_topic` (CreateContainer, CreateItem): a URI reference resolved + against the document's URI, the document's one foaf:primaryTopic. +- ldh-CreateItem's `description`: dct:description. +- ldh-AddObjectBlock's `graph`: the block's ldh:graph. +- ldh-AddResultSetChart's `series_var_name`: a name or a sequence of names, + one ldh:seriesVarName each. +- ldh-AddView's `show_when_empty` (xsd:boolean): ldh:showWhenEmpty. + +Checked offline: every write is recorded and its body read as RDF. +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, Literal, Namespace, URIRef +from rdflib.namespace import DCTERMS, FOAF, RDF, XSD + +from tests.http_stub import request_body_text, request_header +from web_algebra.operation import Operation + +LDH = Namespace("https://w3id.org/atomgraph/linkeddatahub#") +EX = "https://example.org/" +DOC = EX + "doc/" +QUERY = DOC + "#q" +SEQ_1 = RDF["_1"] + + +def written(settings, http_stub, op, **args) -> Graph: + """The graph `op` writes, read from the recorded request bodies.""" + Operation.process_json(settings, {"@op": op, "args": args}) + g = Graph() + for r in http_stub.requests: + if r.get_method() not in ("POST", "PUT"): + continue + content_type = (request_header(r, "Content-Type") or "").split(";")[0].strip() + fmt = {"application/n-triples": "nt", "text/turtle": "turtle", "application/ld+json": "json-ld"}.get(content_type, "nt") + g.parse(data=request_body_text(r), format=fmt, publicID=r.full_url) + return g + + +def container(settings, http_stub, **args) -> tuple[Graph, URIRef]: + g = written(settings, http_stub, "ldh-CreateContainer", parent={"@id": EX}, title="C", slug="c", **args) + return g, URIRef(EX + "c/") + + +def item(settings, http_stub, **args) -> tuple[Graph, URIRef]: + g = written(settings, http_stub, "ldh-CreateItem", container={"@id": EX}, title="I", slug="i", **args) + return g, URIRef(EX + "i/") + + +# --- ldh-CreateContainer: the first content block + + +def test_a_container_shows_its_children_by_default(settings, http_stub): + g, doc = container(settings, http_stub) + (block,) = g.objects(doc, SEQ_1) + assert (block, RDF.type, LDH.Object) in g + assert (block, RDF.value, LDH.ChildrenView) in g + + +def test_a_container_shows_its_children_when_asked(settings, http_stub): + g, doc = container(settings, http_stub, children_view=True) + (block,) = g.objects(doc, SEQ_1) + assert (block, RDF.value, LDH.ChildrenView) in g + + +def test_a_container_without_the_children_view_has_no_content(settings, http_stub): + g, doc = container(settings, http_stub, children_view=False) + assert list(g.objects(doc, SEQ_1)) == [] + assert (None, RDF.value, LDH.ChildrenView) not in g + + +# --- primary_topic + + +@pytest.mark.parametrize("make", [container, item]) +@pytest.mark.parametrize( + "reference, resolved", + [("#this", "{doc}#this"), ("https://example.org/topic", "https://example.org/topic"), ("../topic#it", EX + "topic#it")], +) +def test_the_primary_topic_is_resolved_against_the_document(settings, http_stub, make, reference, resolved): + g, doc = make(settings, http_stub, primary_topic=reference) + assert list(g.objects(doc, FOAF.primaryTopic)) == [URIRef(resolved.format(doc=doc))] + + +@pytest.mark.parametrize("make", [container, item]) +def test_no_primary_topic_unless_given(settings, http_stub, make): + g, doc = make(settings, http_stub) + assert list(g.objects(doc, FOAF.primaryTopic)) == [] + + +# --- ldh-CreateItem: description + + +def test_an_item_has_its_description(settings, http_stub): + g, doc = item(settings, http_stub, description="About the item") + assert list(g.objects(doc, DCTERMS.description)) == [Literal("About the item")] + + +def test_an_item_has_no_description_unless_given(settings, http_stub): + g, doc = item(settings, http_stub) + assert list(g.objects(doc, DCTERMS.description)) == [] + + +# --- ldh-AddObjectBlock: graph + + +def test_an_object_block_reads_its_value_from_the_graph(settings, http_stub): + g = written(settings, http_stub, "ldh-AddObjectBlock", url={"@id": DOC}, value={"@id": EX + "thing"}, + fragment="ob", graph={"@id": EX + "things/thing/"}) + assert list(g.objects(URIRef(DOC + "#ob"), LDH.graph)) == [URIRef(EX + "things/thing/")] + + +def test_an_object_block_has_no_graph_unless_given(settings, http_stub): + g = written(settings, http_stub, "ldh-AddObjectBlock", url={"@id": DOC}, value={"@id": EX + "thing"}, fragment="ob") + assert list(g.objects(URIRef(DOC + "#ob"), LDH.graph)) == [] + + +# --- ldh-AddResultSetChart: several series + + +def chart(settings, http_stub, series) -> Graph: + return written(settings, http_stub, "ldh-AddResultSetChart", url={"@id": DOC}, query={"@id": QUERY}, title="Ch", + chart_type={"@id": "https://w3id.org/atomgraph/client#BarChart"}, category_var_name="year", + series_var_name=series, fragment="ch") + + +def test_one_series(settings, http_stub): + g = chart(settings, http_stub, "sales") + assert set(g.objects(URIRef(DOC + "#ch"), LDH.seriesVarName)) == {Literal("sales")} + + +def test_several_series(settings, http_stub): + g = chart(settings, http_stub, ["sales", "returns", "profit"]) + assert set(g.objects(URIRef(DOC + "#ch"), LDH.seriesVarName)) == {Literal("sales"), Literal("returns"), Literal("profit")} + + +# --- ldh-AddView: show_when_empty + + +def view(settings, http_stub, **args) -> Graph: + return written(settings, http_stub, "ldh-AddView", url={"@id": DOC}, query={"@id": QUERY}, title="V", fragment="v", **args) + + +@pytest.mark.parametrize("value", [False, True]) +def test_show_when_empty(settings, http_stub, value): + g = view(settings, http_stub, show_when_empty=value) + assert list(g.objects(URIRef(DOC + "#v"), LDH.showWhenEmpty)) == [Literal(value, datatype=XSD.boolean)] + + +def test_no_show_when_empty_unless_given(settings, http_stub): + g = view(settings, http_stub) + assert list(g.objects(URIRef(DOC + "#v"), LDH.showWhenEmpty)) == [] + + +def test_show_when_empty_is_a_boolean(settings, http_stub): + with pytest.raises(TypeError): + view(settings, http_stub, show_when_empty="sometimes") + + +# --- the schema names the options + + +@pytest.mark.parametrize( + "op, options", + [ + ("ldh-CreateContainer", {"children_view", "primary_topic"}), + ("ldh-CreateItem", {"description", "primary_topic"}), + ("ldh-AddObjectBlock", {"graph"}), + ("ldh-AddView", {"show_when_empty"}), + ], +) +def test_the_schema_has_the_options(op, options): + schema = Operation.get(op).inputSchema() + assert options <= set(schema["properties"]) + assert not options & set(schema.get("required", []))