web-algebra 1.0.0__py3-none-any.whl
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.
- web_algebra/__init__.py +0 -0
- web_algebra/__main__.py +17 -0
- web_algebra/client.py +245 -0
- web_algebra/json_result.py +168 -0
- web_algebra/main.py +149 -0
- web_algebra/mcp_tool.py +25 -0
- web_algebra/operation.py +250 -0
- web_algebra/operations/__init__.py +0 -0
- web_algebra/operations/bindings.py +59 -0
- web_algebra/operations/current.py +40 -0
- web_algebra/operations/execute.py +56 -0
- web_algebra/operations/filter.py +119 -0
- web_algebra/operations/for_each.py +114 -0
- web_algebra/operations/linked_data/__init__.py +0 -0
- web_algebra/operations/linked_data/get.py +77 -0
- web_algebra/operations/linked_data/patch.py +125 -0
- web_algebra/operations/linked_data/post.py +132 -0
- web_algebra/operations/linked_data/put.py +132 -0
- web_algebra/operations/linkeddatahub/__init__.py +0 -0
- web_algebra/operations/linkeddatahub/add_generic_service.py +303 -0
- web_algebra/operations/linkeddatahub/add_result_set_chart.py +300 -0
- web_algebra/operations/linkeddatahub/add_select.py +233 -0
- web_algebra/operations/linkeddatahub/add_view.py +255 -0
- web_algebra/operations/linkeddatahub/content/__init__.py +0 -0
- web_algebra/operations/linkeddatahub/content/add_object_block.py +311 -0
- web_algebra/operations/linkeddatahub/content/add_xhtml_block.py +277 -0
- web_algebra/operations/linkeddatahub/content/generate_class_containers.py +234 -0
- web_algebra/operations/linkeddatahub/content/generate_ontology_views.py +200 -0
- web_algebra/operations/linkeddatahub/content/generate_portal.py +131 -0
- web_algebra/operations/linkeddatahub/content/remove_block.py +138 -0
- web_algebra/operations/linkeddatahub/create_container.py +190 -0
- web_algebra/operations/linkeddatahub/create_item.py +156 -0
- web_algebra/operations/linkeddatahub/list.py +159 -0
- web_algebra/operations/merge.py +109 -0
- web_algebra/operations/resolve_uri.py +88 -0
- web_algebra/operations/schema/__init__.py +0 -0
- web_algebra/operations/schema/extract_classes.py +55 -0
- web_algebra/operations/schema/extract_datatype_properties.py +119 -0
- web_algebra/operations/schema/extract_object_properties.py +140 -0
- web_algebra/operations/schema/extract_ontology.py +57 -0
- web_algebra/operations/sparql/__init__.py +0 -0
- web_algebra/operations/sparql/construct.py +100 -0
- web_algebra/operations/sparql/describe.py +100 -0
- web_algebra/operations/sparql/select.py +96 -0
- web_algebra/operations/sparql/substitute.py +235 -0
- web_algebra/operations/sparql_string.py +85 -0
- web_algebra/operations/str.py +70 -0
- web_algebra/operations/string/__init__.py +0 -0
- web_algebra/operations/string/concat.py +67 -0
- web_algebra/operations/string/encode_for_uri.py +64 -0
- web_algebra/operations/string/replace.py +131 -0
- web_algebra/operations/struuid.py +39 -0
- web_algebra/operations/uri.py +61 -0
- web_algebra/operations/value.py +66 -0
- web_algebra/operations/variable.py +53 -0
- web_algebra/server.py +131 -0
- web_algebra-1.0.0.dist-info/METADATA +223 -0
- web_algebra-1.0.0.dist-info/RECORD +62 -0
- web_algebra-1.0.0.dist-info/WHEEL +5 -0
- web_algebra-1.0.0.dist-info/entry_points.txt +2 -0
- web_algebra-1.0.0.dist-info/licenses/LICENSE +202 -0
- web_algebra-1.0.0.dist-info/top_level.txt +1 -0
web_algebra/__init__.py
ADDED
|
File without changes
|
web_algebra/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from mcp.server.stdio import stdio_server
|
|
3
|
+
import asyncio
|
|
4
|
+
from .server import server
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def serve():
|
|
10
|
+
options = server.create_initialization_options()
|
|
11
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
12
|
+
logger.info("Starting server with stdio")
|
|
13
|
+
await server.run(read_stream, write_stream, options)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
asyncio.run(serve())
|
web_algebra/client.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import ssl
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
from http.client import HTTPResponse
|
|
6
|
+
from rdflib import Graph
|
|
7
|
+
from rdflib.plugins.sparql.parser import parseQuery
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MEDIA_TYPES = {
|
|
11
|
+
"application/n-triples": "nt",
|
|
12
|
+
"text/turtle": "turtle",
|
|
13
|
+
"application/ld+json": "json-ld",
|
|
14
|
+
"application/rdf+xml": "xml",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HTTPRedirectHandler308(urllib.request.HTTPRedirectHandler):
|
|
19
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
20
|
+
"""Handle 308 Permanent Redirect by preserving method and body"""
|
|
21
|
+
if code == 308:
|
|
22
|
+
return urllib.request.Request(
|
|
23
|
+
newurl, data=req.data, headers=req.headers, method=req.get_method()
|
|
24
|
+
)
|
|
25
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LinkedDataClient:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
cert_pem_path: Optional[str] = None,
|
|
32
|
+
cert_password: Optional[str] = None,
|
|
33
|
+
verify_ssl: bool = True,
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initializes the LinkedDataClient with SSL configuration.
|
|
37
|
+
|
|
38
|
+
:param cert_pem_path: Path to the certificate .pem file (containing both private key and certificate).
|
|
39
|
+
:param cert_password: Password for the encrypted private key in the .pem file.
|
|
40
|
+
:param verify_ssl: Whether to verify the server's SSL certificate. Default is True.
|
|
41
|
+
"""
|
|
42
|
+
# Always create SSL context
|
|
43
|
+
self.ssl_context = ssl.create_default_context()
|
|
44
|
+
|
|
45
|
+
# Load client certificate if provided
|
|
46
|
+
if cert_pem_path and cert_password:
|
|
47
|
+
self.ssl_context.load_cert_chain(
|
|
48
|
+
certfile=cert_pem_path, password=cert_password
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Configure SSL verification
|
|
52
|
+
if not verify_ssl:
|
|
53
|
+
self.ssl_context.check_hostname = False
|
|
54
|
+
self.ssl_context.verify_mode = ssl.CERT_NONE
|
|
55
|
+
|
|
56
|
+
# Create an HTTPS handler with the configured SSL context
|
|
57
|
+
self.opener = urllib.request.build_opener(
|
|
58
|
+
urllib.request.HTTPSHandler(context=self.ssl_context),
|
|
59
|
+
HTTPRedirectHandler308(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Add proper User-Agent header for external services like Wikidata
|
|
63
|
+
self.opener.addheaders = [
|
|
64
|
+
(
|
|
65
|
+
"User-Agent",
|
|
66
|
+
"Web-Algebra/1.0 (LinkedData Processing System; https://github.com/atomgraph/Web-Algebra)",
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def get(self, url: str) -> Graph:
|
|
71
|
+
"""
|
|
72
|
+
Fetches RDF data from the given URL and returns it as an RDFLib Graph.
|
|
73
|
+
|
|
74
|
+
:param url: The URL to fetch RDF data from.
|
|
75
|
+
:return: An RDFLib Graph object containing the parsed RDF data.
|
|
76
|
+
"""
|
|
77
|
+
# Set the Accept header
|
|
78
|
+
accept_header = ", ".join(MEDIA_TYPES.keys())
|
|
79
|
+
headers = {"Accept": accept_header}
|
|
80
|
+
request = urllib.request.Request(url, headers=headers)
|
|
81
|
+
|
|
82
|
+
# Perform the HTTP request
|
|
83
|
+
response = self.opener.open(request)
|
|
84
|
+
|
|
85
|
+
# Read and decode the response data
|
|
86
|
+
data = response.read().decode("utf-8")
|
|
87
|
+
content_type = response.headers.get("Content-Type").split(";")[0]
|
|
88
|
+
rdf_format = MEDIA_TYPES.get(content_type)
|
|
89
|
+
if not rdf_format:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Unsupported Content-Type: {content_type}. Supported types are: {', '.join(MEDIA_TYPES.keys())}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Parse the RDF data into an RDFLib Graph
|
|
95
|
+
g = Graph()
|
|
96
|
+
g.parse(data=data, format=rdf_format, publicID=url)
|
|
97
|
+
return g
|
|
98
|
+
|
|
99
|
+
def post(self, url: str, graph: Graph) -> HTTPResponse:
|
|
100
|
+
"""
|
|
101
|
+
Sends RDF data to the given URL using HTTP POST.
|
|
102
|
+
|
|
103
|
+
:param url: The URL to send RDF data to.
|
|
104
|
+
:param data: An RDFLib Graph containing the data to send.
|
|
105
|
+
:return: The HTTPResponse object.
|
|
106
|
+
"""
|
|
107
|
+
# Serialize the RDF data to N-Triples
|
|
108
|
+
data = graph.serialize(format="nt")
|
|
109
|
+
headers = {
|
|
110
|
+
"Content-Type": "application/n-triples",
|
|
111
|
+
"Accept": "application/n-triples",
|
|
112
|
+
}
|
|
113
|
+
request = urllib.request.Request(
|
|
114
|
+
url, data=data.encode("utf-8"), headers=headers, method="POST"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return self.opener.open(request)
|
|
118
|
+
|
|
119
|
+
def put(self, url: str, graph: Graph) -> HTTPResponse:
|
|
120
|
+
"""
|
|
121
|
+
Sends RDF data to the given URL using HTTP PUT.
|
|
122
|
+
|
|
123
|
+
:param url: The URL to send RDF data to.
|
|
124
|
+
:param data: An RDFLib Graph containing the data to send.
|
|
125
|
+
:return: The HTTPResponse object.
|
|
126
|
+
"""
|
|
127
|
+
# Serialize the RDF data to N-Triples
|
|
128
|
+
data = graph.serialize(format="nt")
|
|
129
|
+
headers = {
|
|
130
|
+
"Content-Type": "application/n-triples",
|
|
131
|
+
"Accept": "application/n-triples",
|
|
132
|
+
}
|
|
133
|
+
request = urllib.request.Request(
|
|
134
|
+
url, data=data.encode("utf-8"), headers=headers, method="PUT"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return self.opener.open(request)
|
|
138
|
+
|
|
139
|
+
def delete(self, url: str) -> HTTPResponse:
|
|
140
|
+
"""
|
|
141
|
+
Sends an HTTP DELETE request to the given URL.
|
|
142
|
+
|
|
143
|
+
:param url: The URL to send the DELETE request to.
|
|
144
|
+
:return: The HTTPResponse object.
|
|
145
|
+
"""
|
|
146
|
+
request = urllib.request.Request(url, method="DELETE")
|
|
147
|
+
|
|
148
|
+
return self.opener.open(request)
|
|
149
|
+
|
|
150
|
+
def patch(self, url: str, sparql_update: str) -> HTTPResponse:
|
|
151
|
+
"""
|
|
152
|
+
Sends a SPARQL UPDATE query to the given URL using HTTP PATCH.
|
|
153
|
+
|
|
154
|
+
:param url: The URL to send the SPARQL UPDATE to.
|
|
155
|
+
:param sparql_update: The SPARQL UPDATE query string.
|
|
156
|
+
:return: The HTTPResponse object.
|
|
157
|
+
"""
|
|
158
|
+
headers = {
|
|
159
|
+
"Content-Type": "application/sparql-update",
|
|
160
|
+
"Accept": "application/n-triples",
|
|
161
|
+
}
|
|
162
|
+
request = urllib.request.Request(
|
|
163
|
+
url, data=sparql_update.encode("utf-8"), headers=headers, method="PATCH"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return self.opener.open(request)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SPARQLClient:
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
cert_pem_path: Optional[str] = None,
|
|
173
|
+
cert_password: Optional[str] = None,
|
|
174
|
+
verify_ssl: bool = True,
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Initializes the SPARQLClient with optional SSL certificate.
|
|
178
|
+
|
|
179
|
+
:param cert_pem_path: Path to .pem file containing cert+key
|
|
180
|
+
:param cert_password: Password for the PEM file
|
|
181
|
+
:param verify_ssl: Whether to verify server SSL certificate
|
|
182
|
+
"""
|
|
183
|
+
# Always create SSL context
|
|
184
|
+
self.ssl_context = ssl.create_default_context()
|
|
185
|
+
|
|
186
|
+
# Load client certificate if provided
|
|
187
|
+
if cert_pem_path and cert_password:
|
|
188
|
+
self.ssl_context.load_cert_chain(
|
|
189
|
+
certfile=cert_pem_path, password=cert_password
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Configure SSL verification
|
|
193
|
+
if not verify_ssl:
|
|
194
|
+
self.ssl_context.check_hostname = False
|
|
195
|
+
self.ssl_context.verify_mode = ssl.CERT_NONE
|
|
196
|
+
|
|
197
|
+
self.opener = urllib.request.build_opener(
|
|
198
|
+
urllib.request.HTTPSHandler(context=self.ssl_context)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Add proper User-Agent header for external services like Wikidata
|
|
202
|
+
self.opener.addheaders = [
|
|
203
|
+
(
|
|
204
|
+
"User-Agent",
|
|
205
|
+
"Web-Algebra/1.0 (LinkedData Processing System; https://github.com/atomgraph/Web-Algebra)",
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
def query(self, endpoint_url: str, query_string: str) -> dict:
|
|
210
|
+
"""
|
|
211
|
+
Executes a SPARQL query. Returns Graph for CONSTRUCT/DESCRIBE, Result for SELECT/ASK.
|
|
212
|
+
|
|
213
|
+
:param endpoint_url: The SPARQL endpoint URL
|
|
214
|
+
:param query_string: SPARQL query string
|
|
215
|
+
:return: rdflib.Graph or rdflib.query.Result
|
|
216
|
+
"""
|
|
217
|
+
parsed = parseQuery(query_string)
|
|
218
|
+
query_type = parsed[1].name # e.g., 'SelectQuery', 'ConstructQuery'
|
|
219
|
+
|
|
220
|
+
if query_type in {"SelectQuery", "AskQuery"}:
|
|
221
|
+
accept = "application/sparql-results+json"
|
|
222
|
+
elif query_type in {"ConstructQuery", "DescribeQuery"}:
|
|
223
|
+
accept = "application/n-triples"
|
|
224
|
+
else:
|
|
225
|
+
raise ValueError(f"Unsupported query type: {query_type}")
|
|
226
|
+
|
|
227
|
+
# Encode URL parameters
|
|
228
|
+
params = urllib.parse.urlencode({"query": query_string})
|
|
229
|
+
url = f"{endpoint_url}?{params}"
|
|
230
|
+
headers = {"Accept": accept}
|
|
231
|
+
|
|
232
|
+
request = urllib.request.Request(url, headers=headers)
|
|
233
|
+
response = self.opener.open(request)
|
|
234
|
+
data = response.read()
|
|
235
|
+
|
|
236
|
+
if accept == "application/n-triples":
|
|
237
|
+
g = Graph()
|
|
238
|
+
# convert N-Triples to JSON-LD
|
|
239
|
+
g.parse(data=data.decode("utf-8"), format="nt")
|
|
240
|
+
jsonld_str = g.serialize(format="json-ld")
|
|
241
|
+
jsonld_data = json.loads(jsonld_str)
|
|
242
|
+
return jsonld_data
|
|
243
|
+
else:
|
|
244
|
+
# return SPARQL JSON results as a dict
|
|
245
|
+
return json.loads(data.decode("utf-8"))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from typing import List, Dict, Iterator
|
|
2
|
+
from rdflib.term import Node
|
|
3
|
+
from rdflib import URIRef, Literal, BNode
|
|
4
|
+
from rdflib.query import Result, ResultRow
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JSONResult(Result):
|
|
8
|
+
"""
|
|
9
|
+
A SPARQL Results container that subclasses rdflib.query.Result,
|
|
10
|
+
can be constructed from SPARQL JSON format and works with RDFLib objects internally.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, vars: List[str], bindings: List[Dict[str, Node]]):
|
|
14
|
+
super().__init__("SELECT") # Always SELECT type for our use case
|
|
15
|
+
self.head = {"vars": vars}
|
|
16
|
+
self.bindings = bindings
|
|
17
|
+
# Set the parent class vars attribute
|
|
18
|
+
self.vars = vars
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_json(cls, json_dict: dict) -> "JSONResult":
|
|
22
|
+
"""Construct from SPARQL JSON format"""
|
|
23
|
+
vars = json_dict["head"]["vars"]
|
|
24
|
+
bindings = []
|
|
25
|
+
|
|
26
|
+
for json_binding in json_dict["results"]["bindings"]:
|
|
27
|
+
rdf_binding = {}
|
|
28
|
+
for var, binding_dict in json_binding.items():
|
|
29
|
+
rdf_binding[var] = cls._parse_binding(binding_dict)
|
|
30
|
+
bindings.append(rdf_binding)
|
|
31
|
+
|
|
32
|
+
return cls(vars, bindings)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _parse_binding(binding_dict: dict) -> Node:
|
|
36
|
+
"""Convert SPARQL JSON binding to RDFLib object"""
|
|
37
|
+
if binding_dict["type"] == "uri":
|
|
38
|
+
return URIRef(binding_dict["value"])
|
|
39
|
+
elif binding_dict["type"] == "literal":
|
|
40
|
+
value = binding_dict["value"]
|
|
41
|
+
datatype = binding_dict.get("datatype")
|
|
42
|
+
lang = binding_dict.get("xml:lang")
|
|
43
|
+
|
|
44
|
+
if datatype:
|
|
45
|
+
datatype = URIRef(datatype)
|
|
46
|
+
|
|
47
|
+
return Literal(value, datatype=datatype, lang=lang)
|
|
48
|
+
elif binding_dict["type"] == "bnode":
|
|
49
|
+
return BNode(binding_dict["value"])
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"Unknown binding type: {binding_dict['type']}")
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _serialize_binding(term: Node) -> dict:
|
|
55
|
+
"""Convert RDFLib object to SPARQL JSON binding"""
|
|
56
|
+
if isinstance(term, URIRef):
|
|
57
|
+
return {"type": "uri", "value": str(term)}
|
|
58
|
+
elif isinstance(term, Literal):
|
|
59
|
+
result = {"type": "literal", "value": str(term)}
|
|
60
|
+
if term.datatype:
|
|
61
|
+
result["datatype"] = str(term.datatype)
|
|
62
|
+
if term.language:
|
|
63
|
+
result["xml:lang"] = term.language
|
|
64
|
+
return result
|
|
65
|
+
elif isinstance(term, BNode):
|
|
66
|
+
return {"type": "bnode", "value": str(term)}
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError(f"Unknown RDFLib term type: {type(term)}")
|
|
69
|
+
|
|
70
|
+
def to_json(self) -> dict:
|
|
71
|
+
"""Convert back to SPARQL JSON format"""
|
|
72
|
+
json_bindings = []
|
|
73
|
+
|
|
74
|
+
for binding in self.bindings:
|
|
75
|
+
json_binding = {}
|
|
76
|
+
for var, term in binding.items():
|
|
77
|
+
json_binding[var] = self._serialize_binding(term)
|
|
78
|
+
json_bindings.append(json_binding)
|
|
79
|
+
|
|
80
|
+
return {"head": {"vars": self.vars}, "results": {"bindings": json_bindings}}
|
|
81
|
+
|
|
82
|
+
def __iter__(self) -> Iterator[ResultRow]:
|
|
83
|
+
"""Allow iteration over bindings as ResultRow objects"""
|
|
84
|
+
for binding in self.bindings:
|
|
85
|
+
yield ResultRow(binding, self.vars)
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
"""Number of bindings"""
|
|
89
|
+
return len(self.bindings)
|
|
90
|
+
|
|
91
|
+
def __getitem__(self, index: int) -> Dict[str, Node]:
|
|
92
|
+
"""Access binding by index"""
|
|
93
|
+
return self.bindings[index]
|
|
94
|
+
|
|
95
|
+
def filter_by_position(self, position: int) -> "JSONResult":
|
|
96
|
+
"""Filter to a single binding by 1-based position (XSLT-style)"""
|
|
97
|
+
if position < 1:
|
|
98
|
+
raise ValueError("Position must be >= 1 (XSLT-style 1-based indexing)")
|
|
99
|
+
|
|
100
|
+
if position > len(self.bindings):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Position {position} exceeds number of bindings ({len(self.bindings)})"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Convert to 0-based index and create new result with single binding
|
|
106
|
+
filtered_binding = self.bindings[position - 1]
|
|
107
|
+
return JSONResult(self.head["vars"], [filtered_binding])
|
|
108
|
+
|
|
109
|
+
# Additional Result interface methods
|
|
110
|
+
|
|
111
|
+
def __bool__(self) -> bool:
|
|
112
|
+
"""Return True if there are any bindings"""
|
|
113
|
+
return len(self.bindings) > 0
|
|
114
|
+
|
|
115
|
+
def __eq__(self, other) -> bool:
|
|
116
|
+
"""Equality comparison"""
|
|
117
|
+
if not isinstance(other, JSONResult):
|
|
118
|
+
return False
|
|
119
|
+
return self.head == other.head and self.bindings == other.bindings
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
"""Developer-friendly representation"""
|
|
123
|
+
return f"JSONResult(vars={self.vars}, bindings={len(self.bindings)} rows)"
|
|
124
|
+
|
|
125
|
+
def __str__(self) -> str:
|
|
126
|
+
"""Pretty table representation"""
|
|
127
|
+
if not self.bindings:
|
|
128
|
+
return f"JSONResult: {self.vars} (0 rows)\n(empty)"
|
|
129
|
+
|
|
130
|
+
# Calculate column widths
|
|
131
|
+
col_widths = {}
|
|
132
|
+
for var in self.vars:
|
|
133
|
+
col_widths[var] = max(
|
|
134
|
+
len(var),
|
|
135
|
+
max(len(str(binding.get(var, "NULL"))) for binding in self.bindings),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Build table
|
|
139
|
+
lines = []
|
|
140
|
+
|
|
141
|
+
# Header
|
|
142
|
+
header = (
|
|
143
|
+
"| " + " | ".join(var.ljust(col_widths[var]) for var in self.vars) + " |"
|
|
144
|
+
)
|
|
145
|
+
separator = (
|
|
146
|
+
"+" + "+".join("-" * (col_widths[var] + 2) for var in self.vars) + "+"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
lines.append(f"JSONResult: {self.vars} ({len(self.bindings)} rows)")
|
|
150
|
+
lines.append(separator)
|
|
151
|
+
lines.append(header)
|
|
152
|
+
lines.append(separator)
|
|
153
|
+
|
|
154
|
+
# Rows
|
|
155
|
+
for binding in self.bindings:
|
|
156
|
+
row = (
|
|
157
|
+
"| "
|
|
158
|
+
+ " | ".join(
|
|
159
|
+
str(binding.get(var, "NULL")).ljust(col_widths[var])
|
|
160
|
+
for var in self.vars
|
|
161
|
+
)
|
|
162
|
+
+ " |"
|
|
163
|
+
)
|
|
164
|
+
lines.append(row)
|
|
165
|
+
|
|
166
|
+
lines.append(separator)
|
|
167
|
+
|
|
168
|
+
return "\n".join(lines)
|
web_algebra/main.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import List, Type, Optional
|
|
8
|
+
import pkgutil
|
|
9
|
+
import importlib
|
|
10
|
+
import inspect
|
|
11
|
+
import rdflib
|
|
12
|
+
from openai import OpenAI
|
|
13
|
+
from pydantic_settings import BaseSettings
|
|
14
|
+
import web_algebra.operations
|
|
15
|
+
from web_algebra.operation import Operation
|
|
16
|
+
|
|
17
|
+
# Configure logging to show INFO level and above
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO, # ✅ Show INFO, WARNING, ERROR, and CRITICAL messages
|
|
20
|
+
format="%(asctime)s - %(levelname)s - %(message)s", # ✅ Add timestamps for clarity
|
|
21
|
+
handlers=[logging.StreamHandler()], # ✅ Ensure output goes to console
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LinkedDataHubSettings(BaseSettings):
|
|
26
|
+
cert_pem_path: Optional[str] = None
|
|
27
|
+
cert_password: Optional[str] = None
|
|
28
|
+
openai_client: Optional[OpenAI] = None
|
|
29
|
+
openai_model: str = "gpt-4o-mini"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_operation_subclasses(
|
|
33
|
+
pkg: ModuleType, base_class: Type[Operation]
|
|
34
|
+
) -> List[Type[Operation]]:
|
|
35
|
+
subclasses = []
|
|
36
|
+
|
|
37
|
+
for loader, module_name, is_pkg in pkgutil.walk_packages(
|
|
38
|
+
pkg.__path__, pkg.__name__ + "."
|
|
39
|
+
):
|
|
40
|
+
module = importlib.import_module(module_name)
|
|
41
|
+
|
|
42
|
+
for name, obj in inspect.getmembers(module, inspect.isclass):
|
|
43
|
+
# Filter: class defined in the module AND is a subclass of Operation (but not Operation itself)
|
|
44
|
+
if (
|
|
45
|
+
obj.__module__ == module.__name__
|
|
46
|
+
and issubclass(obj, base_class)
|
|
47
|
+
and obj is not base_class
|
|
48
|
+
):
|
|
49
|
+
subclasses.append(obj)
|
|
50
|
+
|
|
51
|
+
return subclasses
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register(classes: List[Type[Operation]]):
|
|
55
|
+
for cls in classes:
|
|
56
|
+
Operation.register(cls)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(settings: BaseSettings, json_data: Optional[str]):
|
|
60
|
+
register(list_operation_subclasses(web_algebra.operations, Operation))
|
|
61
|
+
|
|
62
|
+
if json_data:
|
|
63
|
+
logging.info("Executing from JSON input %s", json_data)
|
|
64
|
+
# Load JSON input from file
|
|
65
|
+
with open(json_data) as json_file:
|
|
66
|
+
json_input = json.load(json_file)
|
|
67
|
+
|
|
68
|
+
# Execute the JSON input
|
|
69
|
+
result = Operation.process_json(settings, json_input)
|
|
70
|
+
|
|
71
|
+
# Serialize final result for output
|
|
72
|
+
if isinstance(result, rdflib.Graph):
|
|
73
|
+
# Serialize Graph to JSON-LD for final output
|
|
74
|
+
jsonld_str = result.serialize(format="json-ld")
|
|
75
|
+
print(jsonld_str)
|
|
76
|
+
else:
|
|
77
|
+
print(result)
|
|
78
|
+
else:
|
|
79
|
+
# Load API key
|
|
80
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
81
|
+
if api_key is None:
|
|
82
|
+
sys.exit("Error: Environment variable OPENAI_API_KEY is not set.")
|
|
83
|
+
|
|
84
|
+
client = OpenAI(api_key=api_key)
|
|
85
|
+
model = "gpt-4o-mini"
|
|
86
|
+
|
|
87
|
+
# Load prompts
|
|
88
|
+
with (
|
|
89
|
+
open("prompts/system.md") as system_prompt_file,
|
|
90
|
+
open("prompts/user.template.txt") as user_prompt_template_file,
|
|
91
|
+
):
|
|
92
|
+
system_prompt = system_prompt_file.read()
|
|
93
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
94
|
+
|
|
95
|
+
user_prompt_template = user_prompt_template_file.read()
|
|
96
|
+
|
|
97
|
+
while True:
|
|
98
|
+
instruction = input("Instruction: ")
|
|
99
|
+
|
|
100
|
+
user_prompt = user_prompt_template.format(instruction=instruction)
|
|
101
|
+
messages.append(
|
|
102
|
+
{"role": "user", "content": user_prompt},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
chat_completion = client.chat.completions.create(
|
|
106
|
+
model=model,
|
|
107
|
+
messages=messages,
|
|
108
|
+
response_format={"type": "json_object"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
reply_json = json.loads(chat_completion.choices[0].message.content)
|
|
112
|
+
assert isinstance(reply_json, dict)
|
|
113
|
+
logging.info("Generated response: %s", reply_json)
|
|
114
|
+
|
|
115
|
+
# Execute the generated operation
|
|
116
|
+
print(Operation.process_json(settings, reply_json))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cli() -> None:
|
|
120
|
+
parser = argparse.ArgumentParser(description="Run the AI-powered DSL interpreter.")
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--from-json",
|
|
123
|
+
type=str,
|
|
124
|
+
help="JSON input file to execute",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--cert_pem_path",
|
|
128
|
+
type=str,
|
|
129
|
+
help="Path to the client certificate PEM file",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--cert_password",
|
|
133
|
+
type=str,
|
|
134
|
+
help="Password for the client certificate",
|
|
135
|
+
)
|
|
136
|
+
args = parser.parse_args()
|
|
137
|
+
|
|
138
|
+
if args.cert_pem_path and args.cert_password:
|
|
139
|
+
settings = LinkedDataHubSettings(
|
|
140
|
+
cert_pem_path=args.cert_pem_path, cert_password=args.cert_password
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
settings = BaseSettings()
|
|
144
|
+
|
|
145
|
+
main(settings, args.from_json)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
cli()
|
web_algebra/mcp_tool.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MCPTool(ABC):
|
|
6
|
+
"""
|
|
7
|
+
Interface for operations that can be exposed as MCP (Model Context Protocol) tools.
|
|
8
|
+
|
|
9
|
+
Operations implementing this interface can be called directly from MCP clients
|
|
10
|
+
like Claude Desktop, providing standalone utility functionality.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def mcp_run(self, arguments: dict, context: Any = None) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Execute the operation as an MCP tool with plain arguments.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
arguments: Plain dictionary arguments from MCP client
|
|
20
|
+
context: Optional context (usually None for MCP calls)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of MCP content objects (TextContent, ImageContent, etc.)
|
|
24
|
+
"""
|
|
25
|
+
pass
|