iolanta 2.1.12__py3-none-any.whl → 2.1.13__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.
iolanta/cli/main.py CHANGED
@@ -7,18 +7,25 @@ from typing import Annotated
7
7
  import loguru
8
8
  import platformdirs
9
9
  from documented import DocumentedError
10
- from rdflib import Literal, URIRef
10
+ from rdflib import Graph, Literal, URIRef
11
11
  from rich.console import Console
12
12
  from rich.markdown import Markdown
13
13
  from rich.table import Table
14
- from typer import Argument, Context, Exit, Option, Typer
14
+ from typer import Argument, Exit, Option, Typer
15
15
  from yarl import URL
16
16
 
17
17
  from iolanta.cli.models import LogLevel
18
+ from iolanta.facets.errors import FacetNotFound
18
19
  from iolanta.iolanta import Iolanta
19
20
  from iolanta.models import NotLiteralNode
21
+ from iolanta.namespaces import DATATYPES
22
+ from iolanta.query_result import (
23
+ QueryResult,
24
+ SPARQLParseException,
25
+ SelectResult,
26
+ )
20
27
 
21
- DEFAULT_LANGUAGE = locale.getlocale()[0].split('_')[0]
28
+ DEFAULT_LANGUAGE = locale.getlocale()[0].split("_")[0]
22
29
 
23
30
 
24
31
  console = Console()
@@ -40,23 +47,18 @@ def string_to_node(name: str) -> NotLiteralNode:
40
47
  return URIRef(name)
41
48
 
42
49
  path = Path(name).absolute()
43
- return URIRef(f'file://{path}')
50
+ return URIRef(f"file://{path}")
44
51
 
45
52
 
46
53
  def decode_datatype(datatype: str) -> URIRef:
47
- if datatype.startswith('http'):
54
+ if datatype.startswith("http"):
48
55
  return URIRef(datatype)
49
56
 
50
- return URIRef(f'https://iolanta.tech/datatypes/{datatype}')
57
+ return URIRef(f"https://iolanta.tech/datatypes/{datatype}")
51
58
 
52
59
 
53
- def render_and_return(
54
- url: str,
55
- as_datatype: str,
56
- language: str = DEFAULT_LANGUAGE,
57
- log_level: LogLevel = LogLevel.ERROR,
58
- ):
59
- """Render a given URL."""
60
+ def setup_logging(log_level: LogLevel):
61
+ """Configure and return logger."""
60
62
  level = {
61
63
  LogLevel.DEBUG: logging.DEBUG,
62
64
  LogLevel.INFO: logging.INFO,
@@ -64,111 +66,229 @@ def render_and_return(
64
66
  LogLevel.ERROR: logging.ERROR,
65
67
  }[log_level]
66
68
 
67
- log_file_path = platformdirs.user_log_path(
68
- 'iolanta',
69
- ensure_exists=True,
70
- ) / 'iolanta.log'
69
+ log_file_path = (
70
+ platformdirs.user_log_path(
71
+ "iolanta",
72
+ ensure_exists=True,
73
+ )
74
+ / "iolanta.log"
75
+ )
71
76
 
72
- # Get the level name first
73
77
  level_name = {
74
- logging.DEBUG: 'DEBUG',
75
- logging.INFO: 'INFO',
76
- logging.WARNING: 'WARNING',
77
- logging.ERROR: 'ERROR',
78
+ logging.DEBUG: "DEBUG",
79
+ logging.INFO: "INFO",
80
+ logging.WARNING: "WARNING",
81
+ logging.ERROR: "ERROR",
78
82
  }[level]
79
-
80
- # Configure global loguru logger BEFORE creating Iolanta instance
83
+
81
84
  loguru.logger.remove()
82
85
  loguru.logger.add(
83
86
  log_file_path,
84
87
  level=level_name,
85
- format='{time} {level} {message}',
88
+ format="{time} {level} {message}",
86
89
  enqueue=True,
87
90
  )
88
91
  loguru.logger.add(
89
92
  sys.stderr,
90
93
  level=level_name,
91
- format='{time} | {level:<8} | {name}:{function}:{line} - {message}',
94
+ format="{time} | {level:<8} | {name}:{function}:{line} - {message}",
92
95
  )
93
96
  loguru.logger.level(level_name)
94
-
95
- # Use the global logger
96
- logger = loguru.logger
97
-
98
- node_url = URL(url)
99
- if node_url.scheme and node_url.scheme != 'file':
100
- node = URIRef(url)
101
97
 
102
- iolanta: Iolanta = Iolanta(
103
- language=Literal(language),
104
- logger=logger,
98
+ return loguru.logger
99
+
100
+
101
+ def handle_error(
102
+ error: Exception,
103
+ log_level: LogLevel,
104
+ use_markdown: bool = True,
105
+ ) -> None:
106
+ """
107
+ Handle an error by checking log level and printing appropriately.
108
+
109
+ If log level is DEBUG or INFO, re-raise the error.
110
+ Otherwise, print it and exit with code 1.
111
+ """
112
+ level = {
113
+ LogLevel.DEBUG: logging.DEBUG,
114
+ LogLevel.INFO: logging.INFO,
115
+ LogLevel.WARNING: logging.WARNING,
116
+ LogLevel.ERROR: logging.ERROR,
117
+ }[log_level]
118
+
119
+ if level in {logging.DEBUG, logging.INFO}:
120
+ raise error
121
+
122
+ if use_markdown:
123
+ console.print(
124
+ Markdown(
125
+ str(error),
126
+ justify="left",
127
+ ),
105
128
  )
106
-
107
129
  else:
108
- path = Path(node_url.path).absolute()
109
- node = URIRef(f'file://{path}')
130
+ console.print(str(error))
131
+
132
+ raise Exit(1)
133
+
134
+
135
+ def create_query_node(query_result: QueryResult) -> Literal:
136
+ """
137
+ Create a Literal node from a query result.
138
+
139
+ Converts QueryResult (SelectResult, Graph, or bool) into a Literal
140
+ with the appropriate datatype for facet rendering.
141
+ """
142
+ match query_result:
143
+ case SelectResult():
144
+ return Literal(
145
+ query_result,
146
+ datatype=DATATYPES["sparql-select-result"],
147
+ )
148
+ case Graph():
149
+ return Literal(
150
+ query_result,
151
+ datatype=DATATYPES["sparql-construct-result"],
152
+ )
153
+ case bool():
154
+ return Literal(
155
+ query_result,
156
+ datatype=DATATYPES["sparql-ask-result"],
157
+ )
158
+
159
+
160
+ def render_and_return(
161
+ node: Literal | URIRef,
162
+ as_datatype: str,
163
+ language: str = DEFAULT_LANGUAGE,
164
+ log_level: LogLevel = LogLevel.ERROR,
165
+ ):
166
+ """
167
+ Render a node.
168
+
169
+ The node must be either a URIRef (for URLs) or a Literal (for query results).
170
+ """
171
+ logger = setup_logging(log_level)
172
+
173
+ # Determine Iolanta instance based on node type
174
+ if isinstance(node, Literal):
175
+ # Literal nodes (e.g., from query results) are used directly
176
+ # Use current directory as project_root for query results
110
177
  iolanta: Iolanta = Iolanta(
111
178
  language=Literal(language),
112
179
  logger=logger,
113
- project_root=path,
180
+ project_root=Path.cwd(),
114
181
  )
182
+ elif isinstance(node, URIRef):
183
+ # URIRef - determine project_root if it's a file:// URI
184
+ if str(node).startswith("file://"):
185
+ path = Path(str(node).replace("file://", ""))
186
+ iolanta: Iolanta = Iolanta(
187
+ language=Literal(language),
188
+ logger=logger,
189
+ project_root=path,
190
+ )
191
+ else:
192
+ iolanta: Iolanta = Iolanta(
193
+ language=Literal(language),
194
+ logger=logger,
195
+ )
196
+ else:
197
+ # This should never happen due to type checking, but kept for safety
198
+ raise TypeError(f"Expected Literal or URIRef, got {type(node)}")
115
199
 
116
200
  return iolanta.render(
117
- node=URIRef(node),
201
+ node=node,
118
202
  as_datatype=decode_datatype(as_datatype),
119
203
  )
120
204
 
121
205
 
122
- @app.command(name='render')
123
- def render_command( # noqa: WPS231, WPS238, WPS210, C901
124
- url: Annotated[str, Argument()],
206
+ @app.command(name="render")
207
+ def render_command( # noqa: WPS231, WPS238, WPS210, C901
208
+ url: Annotated[str | None, Argument()] = None,
209
+ query: Annotated[
210
+ str | None,
211
+ Option(
212
+ "--query",
213
+ help="SPARQL query to execute.",
214
+ ),
215
+ ] = None,
125
216
  as_datatype: Annotated[
126
- str, Option(
127
- '--as',
217
+ str | None,
218
+ Option(
219
+ "--as",
128
220
  ),
129
- ] = 'https://iolanta.tech/cli/interactive',
221
+ ] = None,
130
222
  language: Annotated[
131
- str, Option(
132
- help='Data language to prefer.',
223
+ str,
224
+ Option(
225
+ help="Data language to prefer.",
133
226
  ),
134
227
  ] = DEFAULT_LANGUAGE,
135
228
  log_level: LogLevel = LogLevel.ERROR,
136
229
  ):
137
230
  """Render a given URL."""
138
- try:
139
- renderable = render_and_return(url, as_datatype, language, log_level)
140
- except DocumentedError as documented_error:
141
- level = {
142
- LogLevel.DEBUG: logging.DEBUG,
143
- LogLevel.INFO: logging.INFO,
144
- LogLevel.WARNING: logging.WARNING,
145
- LogLevel.ERROR: logging.ERROR,
146
- }[log_level]
147
-
148
- if level in {logging.DEBUG, logging.INFO}:
149
- raise
231
+ if query is not None:
232
+ # For queries, default to 'table' format
233
+ if as_datatype is None:
234
+ as_datatype = "table"
150
235
 
151
- console.print(
152
- Markdown(
153
- str(documented_error),
154
- justify='left',
155
- ),
236
+ # Setup logging and create Iolanta instance (unlikely to raise)
237
+ logger = setup_logging(log_level)
238
+ iolanta: Iolanta = Iolanta(
239
+ language=Literal(language),
240
+ logger=logger,
241
+ project_root=Path.cwd(),
156
242
  )
157
- raise Exit(1)
158
243
 
159
- except Exception as err:
160
- level = {
161
- LogLevel.DEBUG: logging.DEBUG,
162
- LogLevel.INFO: logging.INFO,
163
- LogLevel.WARNING: logging.WARNING,
164
- LogLevel.ERROR: logging.ERROR,
165
- }[log_level]
166
-
167
- if level in {logging.DEBUG, logging.INFO}:
168
- raise
169
-
170
- console.print(str(err))
244
+ try:
245
+ renderable = render_and_return(
246
+ node=create_query_node(iolanta.query(query)),
247
+ as_datatype=as_datatype,
248
+ language=language,
249
+ log_level=log_level,
250
+ )
251
+ except (SPARQLParseException, DocumentedError, FacetNotFound) as error:
252
+ handle_error(error, log_level, use_markdown=True)
253
+ except Exception as error:
254
+ handle_error(error, log_level, use_markdown=False)
255
+ else:
256
+ # FIXME: An intermediary Literal can be used to dispatch rendering.
257
+ match renderable:
258
+ case Table() as table:
259
+ console.print(table)
260
+
261
+ case unknown:
262
+ console.print(unknown)
263
+ return
264
+
265
+ if url is None:
266
+ console.print("Error: Either URL or --query must be provided")
171
267
  raise Exit(1)
268
+
269
+ # For URLs, default to interactive mode
270
+ if as_datatype is None:
271
+ as_datatype = "https://iolanta.tech/cli/interactive"
272
+
273
+ # Parse string URL to URIRef (URL() is permissive and won't raise)
274
+ node_url = URL(url)
275
+ if node_url.scheme and node_url.scheme != "file":
276
+ node = URIRef(url)
277
+ else:
278
+ path = Path(node_url.path).absolute()
279
+ node = URIRef(f"file://{path}")
280
+
281
+ try:
282
+ renderable = render_and_return(
283
+ node=node,
284
+ as_datatype=as_datatype,
285
+ language=language,
286
+ log_level=log_level,
287
+ )
288
+ except DocumentedError as error:
289
+ handle_error(error, log_level, use_markdown=True)
290
+ except Exception as error:
291
+ handle_error(error, log_level, use_markdown=False)
172
292
  else:
173
293
  # FIXME: An intermediary Literal can be used to dispatch rendering.
174
294
  match renderable:
@@ -176,4 +296,4 @@ def render_command( # noqa: WPS231, WPS238, WPS210, C901
176
296
  console.print(table)
177
297
 
178
298
  case unknown:
179
- print(unknown)
299
+ console.print(unknown)
File without changes
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from rdflib import Literal
4
+
5
+ from iolanta.facets.cli.base import Renderable, RichFacet
6
+ from iolanta.facets.errors import NotALiteral
7
+
8
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
9
+
10
+
11
+ class AskResultCsvFacet(RichFacet):
12
+ """Render ASK query results as CSV."""
13
+
14
+ META = META
15
+
16
+ def show(self) -> Renderable:
17
+ """Render bool (ASK result) as CSV."""
18
+ if not isinstance(self.this, Literal):
19
+ raise NotALiteral(node=self.this)
20
+
21
+ query_result = self.this.value
22
+
23
+ return "result\n" + str(query_result).lower()
@@ -0,0 +1,24 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from rdflib import Literal
5
+
6
+ from iolanta.facets.cli.base import Renderable, RichFacet
7
+ from iolanta.facets.errors import NotALiteral
8
+
9
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
10
+
11
+
12
+ class AskResultJsonFacet(RichFacet):
13
+ """Render ASK query results as JSON."""
14
+
15
+ META = META
16
+
17
+ def show(self) -> Renderable:
18
+ """Render bool (ASK result) as JSON."""
19
+ if not isinstance(self.this, Literal):
20
+ raise NotALiteral(node=self.this)
21
+
22
+ query_result = self.this.value
23
+
24
+ return json.dumps(query_result)
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from rdflib import Literal
4
+
5
+ from iolanta.facets.cli.base import Renderable, RichFacet
6
+ from iolanta.facets.errors import NotALiteral
7
+
8
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
9
+
10
+
11
+ class AskResultTableFacet(RichFacet):
12
+ """Render ASK query results as table/text."""
13
+
14
+ META = META
15
+
16
+ def show(self) -> Renderable:
17
+ """Render bool (ASK result) as text."""
18
+ if not isinstance(self.this, Literal):
19
+ raise NotALiteral(node=self.this)
20
+
21
+ query_result = self.this.value
22
+
23
+ return "✅ `True`" if query_result else "❌ `False`"
@@ -0,0 +1,34 @@
1
+ import csv
2
+ import io
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ from rdflib import Graph, Literal
7
+
8
+ from iolanta.facets.cli.base import Renderable, RichFacet
9
+ from iolanta.facets.errors import NotALiteral
10
+
11
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
12
+
13
+
14
+ class ConstructResultCsvFacet(RichFacet):
15
+ """Render CONSTRUCT query results as CSV."""
16
+
17
+ META = META
18
+
19
+ def show(self) -> Renderable:
20
+ """Render Graph (CONSTRUCT result) as CSV."""
21
+ if not isinstance(self.this, Literal):
22
+ raise NotALiteral(node=self.this)
23
+
24
+ graph = cast(Graph, self.this.value)
25
+
26
+ if not graph:
27
+ return ""
28
+
29
+ output = io.StringIO()
30
+ writer = csv.writer(output)
31
+ writer.writerow(('subject', 'predicate', 'object'))
32
+ writer.writerows(graph)
33
+
34
+ return output.getvalue()
@@ -0,0 +1,32 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import cast
4
+
5
+ from rdflib import Graph, Literal
6
+
7
+ from iolanta.facets.cli.base import Renderable, RichFacet
8
+ from iolanta.facets.errors import NotALiteral
9
+
10
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
11
+
12
+
13
+ class ConstructResultJsonFacet(RichFacet):
14
+ """Render CONSTRUCT query results as JSON."""
15
+
16
+ META = META
17
+
18
+ def show(self) -> Renderable:
19
+ """Render Graph (CONSTRUCT result) as JSON."""
20
+ if not isinstance(self.this, Literal):
21
+ raise NotALiteral(node=self.this)
22
+
23
+ graph = cast(Graph, self.this.value)
24
+ fieldnames = ('subject', 'predicate', 'object')
25
+ return json.dumps(
26
+ [
27
+ dict(zip(fieldnames, triple))
28
+ for triple in graph
29
+ ],
30
+ indent=2,
31
+ default=str,
32
+ )
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+ from typing import cast
3
+
4
+ from more_itertools import consume
5
+ from rdflib import Graph, Literal
6
+ from rich.table import Table
7
+
8
+ from iolanta.cli.formatters.pretty import pretty_print_value
9
+ from iolanta.facets.cli.base import Renderable, RichFacet
10
+ from iolanta.facets.errors import NotALiteral
11
+
12
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
13
+
14
+
15
+ class ConstructResultTableFacet(RichFacet):
16
+ """Render CONSTRUCT query results as table."""
17
+
18
+ META = META
19
+
20
+ def show(self) -> Renderable:
21
+ """Render Graph (CONSTRUCT result) as table."""
22
+ if not isinstance(self.this, Literal):
23
+ raise NotALiteral(node=self.this)
24
+
25
+ graph = cast(Graph, self.this.value)
26
+
27
+ if not graph:
28
+ table = Table(
29
+ 'Subject',
30
+ 'Predicate',
31
+ 'Object',
32
+ show_header=True,
33
+ header_style="bold magenta",
34
+ )
35
+ return table
36
+
37
+ table = Table(
38
+ 'Subject',
39
+ 'Predicate',
40
+ 'Object',
41
+ show_header=True,
42
+ header_style="bold magenta",
43
+ )
44
+
45
+ consume(
46
+ table.add_row(
47
+ *[
48
+ str(pretty_print_value(value))
49
+ for value in triple
50
+ ],
51
+ )
52
+ for triple in graph
53
+ )
54
+
55
+ return table
@@ -0,0 +1,102 @@
1
+ "@context":
2
+ "@import": https://json-ld.org/contexts/dollar-convenience.jsonld
3
+ iolanta: https://iolanta.tech/
4
+ rdfs: "http://www.w3.org/2000/01/rdf-schema#"
5
+ rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns#
6
+ owl: http://www.w3.org/2002/07/owl#
7
+
8
+ $: rdfs:label
9
+ →:
10
+ "@type": "@id"
11
+ "@id": iolanta:outputs
12
+ ⊆:
13
+ "@type": "@id"
14
+ "@id": rdfs:subClassOf
15
+ iolanta:hasDatatypeFacet:
16
+ "@type": "@id"
17
+
18
+ $included:
19
+ # Define the three SPARQL result datatypes
20
+ - $id: https://iolanta.tech/datatypes/sparql-select-result
21
+ $type: owl:Class
22
+ ⊆: rdfs:Datatype
23
+ $: SPARQL SELECT Result
24
+ rdfs:comment: Datatype for SPARQL SELECT query results (variable bindings)
25
+
26
+ - $id: https://iolanta.tech/datatypes/sparql-construct-result
27
+ $type: owl:Class
28
+ ⊆: rdfs:Datatype
29
+ $: SPARQL CONSTRUCT Result
30
+ rdfs:comment: Datatype for SPARQL CONSTRUCT query results (RDF graph)
31
+
32
+ - $id: https://iolanta.tech/datatypes/sparql-ask-result
33
+ $type: owl:Class
34
+ ⊆: rdfs:Datatype
35
+ $: SPARQL ASK Result
36
+ rdfs:comment: Datatype for SPARQL ASK query results (boolean)
37
+
38
+ # Register SelectResult facets for each output format
39
+ - $id: pkg:pypi/iolanta#select-result-table-facet
40
+ $type: iolanta:Facet
41
+ $: SPARQL SELECT Result Table Facet
42
+ →: https://iolanta.tech/datatypes/table
43
+
44
+ - $id: pkg:pypi/iolanta#select-result-json-facet
45
+ $type: iolanta:Facet
46
+ $: SPARQL SELECT Result JSON Facet
47
+ →: https://iolanta.tech/datatypes/json
48
+
49
+ - $id: pkg:pypi/iolanta#select-result-csv-facet
50
+ $type: iolanta:Facet
51
+ $: SPARQL SELECT Result CSV Facet
52
+ →: https://iolanta.tech/datatypes/csv
53
+
54
+ - $id: https://iolanta.tech/datatypes/sparql-select-result
55
+ iolanta:hasDatatypeFacet:
56
+ - pkg:pypi/iolanta#select-result-table-facet
57
+ - pkg:pypi/iolanta#select-result-json-facet
58
+ - pkg:pypi/iolanta#select-result-csv-facet
59
+
60
+ # Register ConstructResult facets for each output format
61
+ - $id: pkg:pypi/iolanta#construct-result-table-facet
62
+ $type: iolanta:Facet
63
+ $: SPARQL CONSTRUCT Result Table Facet
64
+ →: https://iolanta.tech/datatypes/table
65
+
66
+ - $id: pkg:pypi/iolanta#construct-result-json-facet
67
+ $type: iolanta:Facet
68
+ $: SPARQL CONSTRUCT Result JSON Facet
69
+ →: https://iolanta.tech/datatypes/json
70
+
71
+ - $id: pkg:pypi/iolanta#construct-result-csv-facet
72
+ $type: iolanta:Facet
73
+ $: SPARQL CONSTRUCT Result CSV Facet
74
+ →: https://iolanta.tech/datatypes/csv
75
+
76
+ - $id: https://iolanta.tech/datatypes/sparql-construct-result
77
+ iolanta:hasDatatypeFacet:
78
+ - pkg:pypi/iolanta#construct-result-table-facet
79
+ - pkg:pypi/iolanta#construct-result-json-facet
80
+ - pkg:pypi/iolanta#construct-result-csv-facet
81
+
82
+ # Register AskResult facets for each output format
83
+ - $id: pkg:pypi/iolanta#ask-result-table-facet
84
+ $type: iolanta:Facet
85
+ $: SPARQL ASK Result Table Facet
86
+ →: https://iolanta.tech/datatypes/table
87
+
88
+ - $id: pkg:pypi/iolanta#ask-result-json-facet
89
+ $type: iolanta:Facet
90
+ $: SPARQL ASK Result JSON Facet
91
+ →: https://iolanta.tech/datatypes/json
92
+
93
+ - $id: pkg:pypi/iolanta#ask-result-csv-facet
94
+ $type: iolanta:Facet
95
+ $: SPARQL ASK Result CSV Facet
96
+ →: https://iolanta.tech/datatypes/csv
97
+
98
+ - $id: https://iolanta.tech/datatypes/sparql-ask-result
99
+ iolanta:hasDatatypeFacet:
100
+ - pkg:pypi/iolanta#ask-result-table-facet
101
+ - pkg:pypi/iolanta#ask-result-json-facet
102
+ - pkg:pypi/iolanta#ask-result-csv-facet
@@ -0,0 +1,36 @@
1
+ import csv
2
+ import io
3
+ from pathlib import Path
4
+
5
+ from rdflib import Literal
6
+
7
+ from iolanta.facets.cli.base import Renderable, RichFacet
8
+ from iolanta.facets.errors import NotALiteral
9
+
10
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
11
+
12
+
13
+ class SelectResultCsvFacet(RichFacet):
14
+ """Render SELECT query results as CSV."""
15
+
16
+ META = META
17
+
18
+ def show(self) -> Renderable:
19
+ """Render SelectResult as CSV."""
20
+ if not isinstance(self.this, Literal):
21
+ raise NotALiteral(node=self.this)
22
+
23
+ query_result = self.this.value
24
+
25
+ if not query_result:
26
+ return ""
27
+
28
+ output = io.StringIO()
29
+ first_row = query_result[0]
30
+ fieldnames = first_row.keys()
31
+
32
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
33
+ writer.writeheader()
34
+ writer.writerows(query_result)
35
+
36
+ return output.getvalue()
@@ -0,0 +1,24 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from rdflib import Literal
5
+
6
+ from iolanta.facets.cli.base import Renderable, RichFacet
7
+ from iolanta.facets.errors import NotALiteral
8
+
9
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
10
+
11
+
12
+ class SelectResultJsonFacet(RichFacet):
13
+ """Render SELECT query results as JSON."""
14
+
15
+ META = META
16
+
17
+ def show(self) -> Renderable:
18
+ """Render SelectResult as JSON."""
19
+ if not isinstance(self.this, Literal):
20
+ raise NotALiteral(node=self.this)
21
+
22
+ query_result = self.this.value
23
+
24
+ return json.dumps(query_result, indent=2, default=str)
@@ -0,0 +1,48 @@
1
+ from pathlib import Path
2
+
3
+ from more_itertools import consume, first
4
+ from rdflib import Literal
5
+ from rich.table import Table
6
+
7
+ from iolanta.cli.formatters.pretty import pretty_print_value
8
+ from iolanta.facets.cli.base import Renderable, RichFacet
9
+ from iolanta.facets.errors import NotALiteral
10
+
11
+ META = Path(__file__).parent / 'data' / 'query_result.yamlld'
12
+
13
+
14
+ class SelectResultTableFacet(RichFacet):
15
+ """Render SELECT query results as table."""
16
+
17
+ META = META
18
+
19
+ def show(self) -> Renderable:
20
+ """Render SelectResult as table."""
21
+ if not isinstance(self.this, Literal):
22
+ raise NotALiteral(node=self.this)
23
+
24
+ query_result = self.this.value
25
+
26
+ if not query_result:
27
+ table = Table(show_header=True, header_style="bold magenta")
28
+ return table
29
+
30
+ columns = first(query_result).keys()
31
+
32
+ table = Table(
33
+ *columns,
34
+ show_header=True,
35
+ header_style="bold magenta",
36
+ )
37
+
38
+ consume(
39
+ table.add_row(
40
+ *[
41
+ str(pretty_print_value(value))
42
+ for value in row.values()
43
+ ],
44
+ )
45
+ for row in query_result
46
+ )
47
+
48
+ return table
iolanta/mcp/cli.py CHANGED
@@ -1,6 +1,9 @@
1
+ from pathlib import Path
1
2
  from typing import Annotated
2
3
 
3
4
  from fastmcp import FastMCP
5
+ from rdflib import URIRef
6
+ from yarl import URL
4
7
 
5
8
  from iolanta.cli.main import render_and_return
6
9
 
@@ -9,11 +12,21 @@ mcp = FastMCP("Iolanta MCP Server")
9
12
 
10
13
  @mcp.tool()
11
14
  def render_uri(
12
- uri: Annotated[str, 'URL, or file system path, to render'],
13
- as_format: Annotated[str, 'Format to render as. Examples: `labeled-triple-set`, `mermaid`'],
15
+ uri: Annotated[str, "URL, or file system path, to render"],
16
+ as_format: Annotated[
17
+ str, "Format to render as. Examples: `labeled-triple-set`, `mermaid`"
18
+ ],
14
19
  ) -> str:
15
20
  """Render a URI."""
16
- return str(render_and_return(uri, as_format))
21
+ # Parse string URL to URIRef
22
+ node_url = URL(uri)
23
+ if node_url.scheme and node_url.scheme != "file":
24
+ node = URIRef(uri)
25
+ else:
26
+ path = Path(node_url.path).absolute()
27
+ node = URIRef(f"file://{path}")
28
+
29
+ return str(render_and_return(node=node, as_datatype=as_format))
17
30
 
18
31
 
19
32
  def app():
iolanta/mermaid/models.py CHANGED
@@ -12,7 +12,10 @@ from iolanta.models import NotLiteralNode # noqa: WPS202
12
12
 
13
13
 
14
14
  def escape_label(label: str) -> str:
15
- """Escape a label to prevent Mermaid from interpreting URLs as markdown links and handle quotes.
15
+ """Escape a label and return it wrapped in appropriate quotes.
16
+
17
+ Returns the label with URLs stripped, quotes escaped, and wrapped in quotes.
18
+ Uses single quotes if label contains double quotes to avoid escaping issues.
16
19
 
17
20
  Escapes quotes in labels that will be wrapped in quotes in Mermaid syntax.
18
21
  """
@@ -20,8 +23,15 @@ def escape_label(label: str) -> str:
20
23
  safe_label = (
21
24
  label.replace("https://", "").replace("http://", "").replace("www.", "")
22
25
  )
23
- # Escape quotes for use inside quoted strings in Mermaid
24
- return safe_label.replace('"', r'\"')
26
+ # Use single quotes if label contains double quotes to avoid escaping issues
27
+ use_single = '"' in safe_label
28
+ quote_char = "'" if use_single else '"'
29
+ # Escape the quote character that will be used for wrapping
30
+ if use_single:
31
+ escaped_label = safe_label.replace("'", r"\'")
32
+ else:
33
+ escaped_label = safe_label.replace('"', r"\"")
34
+ return f"{quote_char}{escaped_label}{quote_char}"
25
35
 
26
36
 
27
37
  class Direction(enum.StrEnum):
@@ -54,14 +64,13 @@ class MermaidURINode(MermaidScalar, frozen=True):
54
64
  def maybe_title(self):
55
65
  if not self.title:
56
66
  return ""
57
- # Escape URLs to prevent Mermaid from interpreting them as markdown links
58
- safe_title = escape_label(self.title)
59
- return f'("{safe_title}")'
67
+ quoted_title = escape_label(self.title)
68
+ return f"({quoted_title})"
60
69
 
61
70
  @property
62
71
  def id(self):
63
72
  return re.sub(
64
- r"[:\/\.#()]", "_", urllib_parse.unquote(str(self.url)).strip("/")
73
+ r"[:\/\.#()?=&+]", "_", urllib_parse.unquote(str(self.url)).strip("/")
65
74
  )
66
75
 
67
76
 
@@ -85,7 +94,7 @@ class MermaidLiteral(MermaidScalar, frozen=True):
85
94
 
86
95
 
87
96
  class MermaidBlankNode(MermaidScalar):
88
- """{self.id}("{self.escaped_title}")"""
97
+ """{self.id}({self.escaped_title})"""
89
98
 
90
99
  node: BNode
91
100
  title: str
@@ -102,7 +111,7 @@ class MermaidBlankNode(MermaidScalar):
102
111
 
103
112
  class MermaidEdge(MermaidScalar):
104
113
  """
105
- {self.source.id} --- {self.id}(["{self.escaped_title}"])--> {self.target.id}
114
+ {self.source.id} --- {self.id}([{self.escaped_title}])--> {self.target.id}
106
115
  click {self.id} "{self.predicate}"
107
116
  class {self.id} predicate
108
117
  """
@@ -130,7 +139,7 @@ class MermaidEdge(MermaidScalar):
130
139
 
131
140
  class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
132
141
  """
133
- subgraph {self.id}["{self.escaped_title}"]
142
+ subgraph {self.id}[{self.escaped_title}]
134
143
  direction {self.direction}
135
144
  {self.formatted_body}
136
145
  end
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iolanta
3
- Version: 2.1.12
3
+ Version: 2.1.13
4
4
  Summary: Semantic Web browser
5
5
  License: MIT
6
6
  Author: Anatoly Scherbakov
@@ -6,7 +6,7 @@ iolanta/cli/formatters/choose.py,sha256=LWzsO_9IBSSgYNIyLlItkp8TNvpW3v6YCQ8-6kbI
6
6
  iolanta/cli/formatters/csv.py,sha256=ceJ_DTz0beqeK-d6FPBQqqjXrziEfF0FRSLoGZCt_fs,760
7
7
  iolanta/cli/formatters/json.py,sha256=Og5B9UrSM_0NWqW5Afpsy6WH8ZfYgPMVXYvT3i-43Jc,748
8
8
  iolanta/cli/formatters/pretty.py,sha256=IypZRAr2vNqcXFY6NOIc75mpyfpFWh56aCBlOPDDieQ,2901
9
- iolanta/cli/main.py,sha256=CZ22-DQY3xHvvUiq1o9XDrpC2an4429yQNOE8LytDkg,4524
9
+ iolanta/cli/main.py,sha256=T1l1ilWp818pF3oR6VJIlPQ_ElicN7ECGlcJm5XiUqo,8170
10
10
  iolanta/cli/models.py,sha256=cjbpowdzI4wAP0DUk3qoVHyimk6AZwlXi9CGmusZTuM,159
11
11
  iolanta/cli/pretty_print.py,sha256=M6E3TmhzA6JY5GeUVmDZLmOh5u70-393PVit4voFKDI,977
12
12
  iolanta/context.py,sha256=bZR-tbZIrDQ-Vby01PMDZ6ifxM-0YMK68RJvAsyqCTs,507
@@ -61,6 +61,17 @@ iolanta/facets/mkdocs_material_insiders_markdown/facet.py,sha256=EY-H4gR5YgiDVt2
61
61
  iolanta/facets/mkdocs_material_insiders_markdown/templates/datatype.jinja2.md,sha256=k9GSdy27mAY3eRL899pk6ZCYr4ZpEY1EuM5RY-OApYM,551
62
62
  iolanta/facets/page_title.py,sha256=TwgZK2g_e5UoWYjKNgDzzkmq1EI3cY58680iC8N9kZI,1407
63
63
  iolanta/facets/qname.py,sha256=Z2wjDWV90Z4vuwLj31MSf5EBGTb0dxzjlKl-Iv4dPao,533
64
+ iolanta/facets/query/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
+ iolanta/facets/query/ask_result_csv.py,sha256=oDuk_a8RiTsyXZuPraUSKswC7dLTCjSFJU8tyRaLALY,589
66
+ iolanta/facets/query/ask_result_json.py,sha256=qXKIg1AdVPixd3SDY_nyXleJ7y4Q94kPFnanzsCmcTM,590
67
+ iolanta/facets/query/ask_result_table.py,sha256=QdmrhKofca7ZOca9SN7GA3Riite1NPOsrKdLvW0xXkU,608
68
+ iolanta/facets/query/construct_result_csv.py,sha256=70KYP9aSnGSm_Gxk6Wjcik4YkvsdmAlO5EwNVOE59Nc,850
69
+ iolanta/facets/query/construct_result_json.py,sha256=mdaxGReivhHGtK_0OCMky6BcQ7EGth0-nEUMNy4XnpE,856
70
+ iolanta/facets/query/construct_result_table.py,sha256=bCUHj0eW086Rx7g6gW432j8Js4vFNCNceH_AJhnJ7po,1398
71
+ iolanta/facets/query/data/query_result.yamlld,sha256=uutBc4XEYh3LSVeYdvrs5uVsdDU7e9TQ2TQEtDfzaks,3440
72
+ iolanta/facets/query/select_result_csv.py,sha256=Js5h_MJ-DgWGJbhypuLoJ0UiW7wsS8rG23aMCAOGdd0,880
73
+ iolanta/facets/query/select_result_json.py,sha256=gajYXgROSWphYeimfO0W1xumsMLdpy_d57QgYQEI3a4,614
74
+ iolanta/facets/query/select_result_table.py,sha256=ePeUydu4bV7MZciZriPreIKgshrtfd8qn9qQC59hsBg,1236
64
75
  iolanta/facets/textual_browser/__init__.py,sha256=sKgDvXOwib9n9d63kdtKCEv26-FoL0VN6zxDmfcheZ8,104
65
76
  iolanta/facets/textual_browser/app.py,sha256=rF34KbWi0L-Mozwzm-wzBS-3OqCcwbaXl0GZCl4YpAg,3503
66
77
  iolanta/facets/textual_browser/facet.py,sha256=5mX1l6P-Ga7buzXmItxSpta6G_D4Fvwv8H6mU8-3g80,742
@@ -124,11 +135,11 @@ iolanta/labeled_triple_set/data/labeled_triple_set.yamlld,sha256=P3oAPSPsirpbcRX
124
135
  iolanta/labeled_triple_set/labeled_triple_set.py,sha256=o4IgvTvPd0mzBtpgHYd4n1xpujYdAvWBr6gIYwp5vnA,4061
125
136
  iolanta/labeled_triple_set/sparql/triples.sparql,sha256=VsCmYN5AX7jSIiFm-SqLcRcOvUVj8yyZI4PSzKROtQw,82
126
137
  iolanta/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
127
- iolanta/mcp/cli.py,sha256=wlDandYo_VoMIg9oDGPJsIsMmiPo4j4-RBytIKWDtEI,484
138
+ iolanta/mcp/cli.py,sha256=QRHrkGnfxXNZx_iUwTbXz4zZwvp5N_ceKoB8ae_fLzY,823
128
139
  iolanta/mermaid/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
129
140
  iolanta/mermaid/facet.py,sha256=8mLOBrzlY84jiWhtJNY5BkXPDpRhL2OB1LUwwwNS1X0,4065
130
141
  iolanta/mermaid/mermaid.yamlld,sha256=G_8lqNfs6S7pz9koDC8xAaic4EaHsYnFLgexDVyMuCU,552
131
- iolanta/mermaid/models.py,sha256=5lvIu_M3FtoM2tAU7dL6WOMzHwr1ix0BdfOKtiU7Y-Q,4969
142
+ iolanta/mermaid/models.py,sha256=OzY54DZbxmOCqlfyfin2h8NR3EytdgwlNhbpnY_PH7Y,5330
132
143
  iolanta/mermaid/sparql/ask-has-triples.sparql,sha256=mOYJ_rutEG_15PKTCHSv2GqzbkAawIn1U2kjkIr_Me0,41
133
144
  iolanta/mermaid/sparql/graph.sparql,sha256=mDGf05od3CUFhzI6rcqt5ZMVy-gSKDu-WxmV_zpIsVI,62
134
145
  iolanta/mermaid/sparql/subgraphs.sparql,sha256=VuoOYr_ZtKXXRrBpAEJek0mBRzR9EV-KnKENgAbkzCs,71
@@ -154,7 +165,7 @@ iolanta/sparqlspace/sparqlspace.py,sha256=Y8_ZPXwuGEXbEes6XQjaQWA2Zv9y8SWxMPDFdq
154
165
  iolanta/widgets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
155
166
  iolanta/widgets/description.py,sha256=98Qd3FwT9r8sYqKjl9ZEptaVX9jJ2ULWf0uy3j52p5o,800
156
167
  iolanta/widgets/mixin.py,sha256=nDRCOc-gizCf1a5DAcYs4hW8eZEd6pHBPFsfm0ncv7E,251
157
- iolanta-2.1.12.dist-info/METADATA,sha256=T4Ee-_-SCeBpfvtXKzAmtNRG3Af_EoWeHGYu1uaUYYE,2317
158
- iolanta-2.1.12.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
159
- iolanta-2.1.12.dist-info/entry_points.txt,sha256=2WLr6410ZI7lwzxevIn74trs3U_y4Rx8aK3N3NT_YPw,1786
160
- iolanta-2.1.12.dist-info/RECORD,,
168
+ iolanta-2.1.13.dist-info/METADATA,sha256=9qsxicf-PrUK46WZ45pVAIpxd9d66ZgSNXkcncdfeFU,2317
169
+ iolanta-2.1.13.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
170
+ iolanta-2.1.13.dist-info/entry_points.txt,sha256=Z1f3OaNruE2a6eprkiLgcPw-lZCah_cRT-PTlDD-Y1s,2569
171
+ iolanta-2.1.13.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -4,7 +4,13 @@ iolanta-mcp=iolanta.mcp.cli:app
4
4
  sparqlspace=iolanta.sparqlspace.cli:app
5
5
 
6
6
  [iolanta.facets]
7
+ ask-result-csv-facet=iolanta.facets.query.ask_result_csv:AskResultCsvFacet
8
+ ask-result-json-facet=iolanta.facets.query.ask_result_json:AskResultJsonFacet
9
+ ask-result-table-facet=iolanta.facets.query.ask_result_table:AskResultTableFacet
7
10
  boolean=iolanta.facets.generic:BoolLiteral
11
+ construct-result-csv-facet=iolanta.facets.query.construct_result_csv:ConstructResultCsvFacet
12
+ construct-result-json-facet=iolanta.facets.query.construct_result_json:ConstructResultJsonFacet
13
+ construct-result-table-facet=iolanta.facets.query.construct_result_table:ConstructResultTableFacet
8
14
  icon=iolanta.facets.icon:IconFacet
9
15
  labeled-triple-set=iolanta.labeled_triple_set.labeled_triple_set:LabeledTripleSet
10
16
  mermaid-graph=iolanta.mermaid.facet:Mermaid
@@ -12,6 +18,9 @@ mermaid-roadmap=iolanta.facets.mermaid_roadmap.facet:MermaidRoadmap
12
18
  mkdocs-material-insiders-markdown-datatype=iolanta.facets.mkdocs_material_insiders_markdown:MkDocsMaterialInsidersMarkdownFacet
13
19
  qname=iolanta.facets.qname:QNameFacet
14
20
  rich-declension-table=iolanta.declension.facet:RichDeclensionTable
21
+ select-result-csv-facet=iolanta.facets.query.select_result_csv:SelectResultCsvFacet
22
+ select-result-json-facet=iolanta.facets.query.select_result_json:SelectResultJsonFacet
23
+ select-result-table-facet=iolanta.facets.query.select_result_table:SelectResultTableFacet
15
24
  textual-browser=iolanta.facets.textual_browser:TextualBrowserFacet
16
25
  textual-class=iolanta.facets.textual_class:Class
17
26
  textual-graph=iolanta.facets.textual_graph:GraphFacet