iolanta 2.0.8__py3-none-any.whl → 2.1.4__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 +45 -25
- iolanta/data/graph-triples.yamlld +2 -2
- iolanta/data/textual-browser.yaml +23 -22
- iolanta/declension/__init__.py +0 -0
- iolanta/declension/data/declension.yamlld +39 -0
- iolanta/declension/facet.py +44 -0
- iolanta/declension/sparql/declension.sparql +8 -0
- iolanta/facets/cli/record.py +2 -2
- iolanta/facets/facet.py +1 -22
- iolanta/facets/foaf_person_title/facet.py +2 -2
- iolanta/facets/generic/bool_literal.py +3 -3
- iolanta/facets/generic/date_literal.py +1 -1
- iolanta/facets/generic/default.py +2 -2
- iolanta/facets/html/code_literal.py +3 -3
- iolanta/facets/icon.py +1 -1
- iolanta/facets/locator.py +1 -4
- iolanta/facets/qname.py +2 -2
- iolanta/facets/textual_browser/app.py +7 -3
- iolanta/facets/textual_browser/facet.py +1 -1
- iolanta/facets/textual_browser/page_switcher.py +13 -18
- iolanta/facets/textual_class/facets.py +3 -3
- iolanta/facets/textual_class/sparql/instances.sparql +4 -1
- iolanta/facets/textual_default/facets.py +5 -5
- iolanta/facets/textual_default/widgets.py +1 -1
- iolanta/facets/textual_graph/facets.py +3 -3
- iolanta/facets/textual_graph_triples.py +1 -1
- iolanta/facets/textual_link/facet.py +4 -4
- iolanta/facets/textual_nanopublication/facet.py +1 -1
- iolanta/facets/textual_no_facet_found.py +3 -1
- iolanta/facets/textual_ontology/facets.py +3 -3
- iolanta/facets/textual_provenance/facets.py +1 -1
- iolanta/facets/title/facets.py +3 -5
- iolanta/facets/title/sparql/title.sparql +5 -0
- iolanta/facets/wikibase_statement_title/facets.py +2 -2
- iolanta/iolanta.py +35 -68
- iolanta/labeled_triple_set/__init__.py +0 -0
- iolanta/labeled_triple_set/data/labeled_triple_set.yamlld +42 -0
- iolanta/labeled_triple_set/labeled_triple_set.py +137 -0
- iolanta/labeled_triple_set/sparql/triples.sparql +5 -0
- iolanta/mermaid/__init__.py +0 -0
- iolanta/mermaid/facet.py +127 -0
- iolanta/mermaid/mermaid.yamlld +42 -0
- iolanta/mermaid/models.py +156 -0
- iolanta/mermaid/sparql/graph.sparql +5 -0
- iolanta/mermaid/sparql/subgraphs.sparql +3 -0
- iolanta/namespaces.py +1 -1
- iolanta/resolvers/base.py +2 -1
- iolanta/resolvers/dispatch.py +41 -0
- iolanta/resolvers/pypi.py +106 -0
- iolanta/resolvers/python_import.py +9 -32
- iolanta/sparqlspace/processor.py +4 -1
- {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/METADATA +7 -8
- {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/RECORD +55 -41
- {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/WHEEL +1 -1
- iolanta-2.1.4.dist-info/entry_points.txt +33 -0
- iolanta/data/cli.yaml +0 -30
- iolanta/data/html.yaml +0 -15
- iolanta-2.0.8.dist-info/entry_points.txt +0 -10
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated, Iterable
|
|
3
|
+
from typing import Literal as TypingLiteral
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
AnyUrl,
|
|
7
|
+
BaseModel,
|
|
8
|
+
Field,
|
|
9
|
+
TypeAdapter,
|
|
10
|
+
field_serializer,
|
|
11
|
+
field_validator,
|
|
12
|
+
validator,
|
|
13
|
+
)
|
|
14
|
+
from rdflib import BNode, Literal, Node, URIRef
|
|
15
|
+
|
|
16
|
+
from iolanta import Facet
|
|
17
|
+
from iolanta.models import NotLiteralNode
|
|
18
|
+
from iolanta.namespaces import DATATYPES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WithFeedback(BaseModel):
|
|
22
|
+
feedback: Annotated[list[str], Field(default_factory=list)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LabeledURI(WithFeedback, BaseModel):
|
|
26
|
+
type: TypingLiteral['uri'] = 'uri'
|
|
27
|
+
uri: AnyUrl
|
|
28
|
+
label: str
|
|
29
|
+
|
|
30
|
+
def construct_feedback(self) -> Iterable[str]:
|
|
31
|
+
if str(self.label) == str(self.uri):
|
|
32
|
+
yield (
|
|
33
|
+
'For this URI, the label is the same as the URI. We were '
|
|
34
|
+
'unable to render that URI.'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LabeledBlank(WithFeedback, BaseModel):
|
|
39
|
+
type: TypingLiteral['blank'] = 'blank'
|
|
40
|
+
identifier: str
|
|
41
|
+
label: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LabeledLiteral(WithFeedback, BaseModel):
|
|
45
|
+
type: TypingLiteral['literal'] = 'literal'
|
|
46
|
+
value: str
|
|
47
|
+
language: str | None
|
|
48
|
+
datatype: str | None
|
|
49
|
+
label: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LabeledTriple(BaseModel):
|
|
53
|
+
subject: LabeledURI | LabeledBlank
|
|
54
|
+
predicate: LabeledURI
|
|
55
|
+
object_: LabeledURI | LabeledBlank | LabeledLiteral
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def construct_uri_feedback(uri: AnyUrl, label: str) -> Iterable[str]:
|
|
59
|
+
if str(uri) == str(label):
|
|
60
|
+
yield (
|
|
61
|
+
'For this URI, the label is the same as the URI. We were '
|
|
62
|
+
'unable to render that URI.'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def construct_blank_feedback(bnode, label) -> Iterable[str]:
|
|
67
|
+
if str(bnode) == str(label):
|
|
68
|
+
yield (
|
|
69
|
+
'For this blank node, the label is the same as the blank node. '
|
|
70
|
+
'We were unable to render that blank node.'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def construct_literal_feedback(literal, label):
|
|
75
|
+
if label.startswith('http'):
|
|
76
|
+
yield (
|
|
77
|
+
'This RDF literal seems to be actually a URL. Good chance is '
|
|
78
|
+
'that it should not be a literal.'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
elif ':' in label[:5]:
|
|
82
|
+
yield (
|
|
83
|
+
'This RDF literal seems to be actually a QName (prefixed URI). '
|
|
84
|
+
'Good chance is that it should not be a literal.'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LabeledTripleSet(Facet[list[LabeledTriple]]):
|
|
89
|
+
"""A set of labeled triples."""
|
|
90
|
+
|
|
91
|
+
META = Path(__file__).parent / 'data' / 'labeled_triple_set.yamlld'
|
|
92
|
+
|
|
93
|
+
def render_label(self, node: NotLiteralNode) -> str:
|
|
94
|
+
return self.render(node, as_datatype=DATATYPES.title)
|
|
95
|
+
|
|
96
|
+
def parse_term(self, term: Node):
|
|
97
|
+
match term:
|
|
98
|
+
case URIRef() as uriref:
|
|
99
|
+
uri = AnyUrl(uriref)
|
|
100
|
+
label = self.render_label(uriref)
|
|
101
|
+
|
|
102
|
+
return LabeledURI(
|
|
103
|
+
uri=uri,
|
|
104
|
+
label=label,
|
|
105
|
+
feedback=list(construct_uri_feedback(uri=uri, label=label)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
case BNode() as bnode:
|
|
109
|
+
label = self.render_label(bnode)
|
|
110
|
+
return LabeledBlank(
|
|
111
|
+
identifier=bnode,
|
|
112
|
+
label=label,
|
|
113
|
+
feedback=list(construct_blank_feedback(bnode=bnode, label=label)),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
case Literal() as literal:
|
|
117
|
+
label = self.render_label(literal)
|
|
118
|
+
return LabeledLiteral(
|
|
119
|
+
value=literal,
|
|
120
|
+
language=literal.language,
|
|
121
|
+
datatype=literal.datatype,
|
|
122
|
+
label=label,
|
|
123
|
+
feedback=list(construct_literal_feedback(literal=literal, label=label)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def show(self):
|
|
127
|
+
rows = self.stored_query('triples.sparql', graph=self.this)
|
|
128
|
+
triples = [
|
|
129
|
+
LabeledTriple(
|
|
130
|
+
subject=self.parse_term(row['subject']),
|
|
131
|
+
predicate=self.parse_term(row['predicate']),
|
|
132
|
+
object_=self.parse_term(row['object']),
|
|
133
|
+
)
|
|
134
|
+
for row in rows
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
return TypeAdapter(list[LabeledTriple]).dump_json(triples, indent=2).decode()
|
|
File without changes
|
iolanta/mermaid/facet.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
|
|
5
|
+
import boltons
|
|
6
|
+
import cachetools
|
|
7
|
+
import funcy
|
|
8
|
+
from boltons.cacheutils import cached, cachedmethod
|
|
9
|
+
from pydantic import AnyUrl
|
|
10
|
+
from rdflib import BNode, Literal, Node, URIRef
|
|
11
|
+
|
|
12
|
+
from iolanta import Facet
|
|
13
|
+
from iolanta.mermaid.models import (
|
|
14
|
+
Diagram,
|
|
15
|
+
MermaidBlankNode,
|
|
16
|
+
MermaidEdge,
|
|
17
|
+
MermaidLiteral,
|
|
18
|
+
MermaidScalar,
|
|
19
|
+
MermaidSubgraph,
|
|
20
|
+
MermaidURINode,
|
|
21
|
+
)
|
|
22
|
+
from iolanta.models import NotLiteralNode
|
|
23
|
+
from iolanta.namespaces import DATATYPES
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def filter_edges(edges: Iterable[MermaidEdge]) -> Iterable[MermaidEdge]:
|
|
27
|
+
for edge in edges:
|
|
28
|
+
if isinstance(edge.target, MermaidLiteral) and edge.source.title == edge.target.title:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
yield edge
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def filter_nodes(edges: Iterable[MermaidEdge], except_uris: Iterable[NotLiteralNode]) -> Iterable[MermaidURINode | MermaidLiteral | MermaidBlankNode]:
|
|
35
|
+
nodes = [
|
|
36
|
+
node
|
|
37
|
+
for edge in edges
|
|
38
|
+
for node in edge.nodes
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
literals_in_edges = {edge.target for edge in edges if isinstance(edge.target, MermaidLiteral)}
|
|
42
|
+
for node in nodes:
|
|
43
|
+
if isinstance(node, MermaidLiteral) and node not in literals_in_edges:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if isinstance(node, MermaidURINode) and node.uri in except_uris:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
if isinstance(node, MermaidBlankNode) and node.node in except_uris:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if isinstance(node, MermaidSubgraph):
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
yield node
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Mermaid(Facet[str]):
|
|
59
|
+
"""Mermaid diagram."""
|
|
60
|
+
|
|
61
|
+
META = Path(__file__).parent / 'mermaid.yamlld'
|
|
62
|
+
|
|
63
|
+
def as_mermaid(self, node: Node):
|
|
64
|
+
match node:
|
|
65
|
+
case URIRef() as uri:
|
|
66
|
+
if uri in self.subgraph_uris:
|
|
67
|
+
return MermaidSubgraph(children=[], uri=uri, title=self.render(uri, as_datatype=DATATYPES.title))
|
|
68
|
+
|
|
69
|
+
return MermaidURINode(uri=uri, url=AnyUrl(uri), title=self.render(uri, as_datatype=DATATYPES.title))
|
|
70
|
+
case Literal() as literal:
|
|
71
|
+
return MermaidLiteral(literal=literal)
|
|
72
|
+
case BNode() as bnode:
|
|
73
|
+
return MermaidBlankNode(node=bnode, title=self.render(bnode, as_datatype=DATATYPES.title))
|
|
74
|
+
case unknown:
|
|
75
|
+
unknown_type = type(unknown)
|
|
76
|
+
raise ValueError(f'Unknown something: {unknown} ({unknown_type})')
|
|
77
|
+
|
|
78
|
+
def construct_mermaid_for_graph(self, graph: URIRef) -> Iterable[MermaidScalar]:
|
|
79
|
+
"""Render graph as mermaid."""
|
|
80
|
+
rows = self.stored_query('graph.sparql', this=graph)
|
|
81
|
+
edges = [
|
|
82
|
+
MermaidEdge(
|
|
83
|
+
source=self.as_mermaid(row['s']),
|
|
84
|
+
target=self.as_mermaid(row['o']),
|
|
85
|
+
title=self.render(row['p'], as_datatype=DATATYPES.title),
|
|
86
|
+
predicate=row['p'],
|
|
87
|
+
) for row in rows
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
edges = list(filter_edges(edges))
|
|
91
|
+
nodes = list(
|
|
92
|
+
filter_nodes(
|
|
93
|
+
edges=edges,
|
|
94
|
+
except_uris=self.subgraph_uris,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return *nodes, *edges
|
|
99
|
+
|
|
100
|
+
@functools.cached_property
|
|
101
|
+
def subgraph_uris(self) -> set[NotLiteralNode]:
|
|
102
|
+
return set(
|
|
103
|
+
funcy.pluck(
|
|
104
|
+
'subgraph',
|
|
105
|
+
self.stored_query('subgraphs.sparql', this=self.this),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def construct_mermaid_subgraphs(self) -> Iterable[MermaidSubgraph]:
|
|
110
|
+
for subgraph_uri in self.subgraph_uris:
|
|
111
|
+
children = list(self.construct_mermaid_for_graph(subgraph_uri))
|
|
112
|
+
if children:
|
|
113
|
+
title = self.render(subgraph_uri, as_datatype=DATATYPES.title)
|
|
114
|
+
yield MermaidSubgraph(
|
|
115
|
+
children=children,
|
|
116
|
+
uri=subgraph_uri,
|
|
117
|
+
title=title,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def show(self) -> str:
|
|
121
|
+
"""Render mermaid diagram."""
|
|
122
|
+
direct_children = self.construct_mermaid_for_graph(self.this)
|
|
123
|
+
subgraphs = self.construct_mermaid_subgraphs()
|
|
124
|
+
return str(Diagram(children=[*direct_children, *subgraphs]))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"@context":
|
|
2
|
+
"@import": https://json-ld.org/contexts/dollar-convenience.jsonld
|
|
3
|
+
vann: https://purl.org/vocab/vann/
|
|
4
|
+
foaf: https://xmlns.com/foaf/0.1/
|
|
5
|
+
owl: https://www.w3.org/2002/07/owl#
|
|
6
|
+
iolanta: https://iolanta.tech/
|
|
7
|
+
rdfs: "https://www.w3.org/2000/01/rdf-schema#"
|
|
8
|
+
rdf: https://www.w3.org/1999/02/22-rdf-syntax-ns#
|
|
9
|
+
dcterms: https://purl.org/dc/terms/
|
|
10
|
+
dcam: https://purl.org/dc/dcam/
|
|
11
|
+
|
|
12
|
+
iolanta:outputs:
|
|
13
|
+
"@type": "@id"
|
|
14
|
+
|
|
15
|
+
iolanta:when-no-facet-found:
|
|
16
|
+
"@type": "@id"
|
|
17
|
+
|
|
18
|
+
$: rdfs:label
|
|
19
|
+
→:
|
|
20
|
+
"@type": "@id"
|
|
21
|
+
"@id": iolanta:outputs
|
|
22
|
+
|
|
23
|
+
⊆:
|
|
24
|
+
"@type": "@id"
|
|
25
|
+
"@id": rdfs:subClassOf
|
|
26
|
+
|
|
27
|
+
⪯:
|
|
28
|
+
"@type": "@id"
|
|
29
|
+
"@id": iolanta:is-preferred-over
|
|
30
|
+
|
|
31
|
+
↦: iolanta:matches
|
|
32
|
+
iolanta:hasDefaultFacet:
|
|
33
|
+
"@type": "@id"
|
|
34
|
+
|
|
35
|
+
$id: pkg:pypi/iolanta#mermaid-graph
|
|
36
|
+
$: Mermaid Graph
|
|
37
|
+
→:
|
|
38
|
+
$id: https://iolanta.tech/datatypes/mermaid
|
|
39
|
+
$: Mermaid
|
|
40
|
+
↦:
|
|
41
|
+
- ASK WHERE { GRAPH $this { ?s ?p ?o } }
|
|
42
|
+
- ASK WHERE { $this iolanta:has-sub-graph ?subgraph }
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
import textwrap
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def escape_label(label: str) -> str:
|
|
9
|
+
"""Escape a label to prevent Mermaid from interpreting URLs as markdown links."""
|
|
10
|
+
# Remove https://, http://, and www. prefixes to prevent markdown link parsing
|
|
11
|
+
return label.replace('https://', '').replace('http://', '').replace('www.', '')
|
|
12
|
+
|
|
13
|
+
from documented import Documented
|
|
14
|
+
from pydantic import AnyUrl, BaseModel
|
|
15
|
+
from rdflib import BNode, Literal, URIRef
|
|
16
|
+
|
|
17
|
+
from iolanta.models import NotLiteralNode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Direction(enum.StrEnum):
|
|
21
|
+
"""Mermaid diagram direction."""
|
|
22
|
+
|
|
23
|
+
TB = 'TB'
|
|
24
|
+
LR = 'LR'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MermaidURINode(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
|
|
28
|
+
"""
|
|
29
|
+
{self.id}{self.maybe_title}
|
|
30
|
+
click {self.id} "{self.url}"
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
uri: URIRef
|
|
34
|
+
url: AnyUrl
|
|
35
|
+
title: str = ''
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def maybe_title(self):
|
|
39
|
+
if not self.title:
|
|
40
|
+
return ''
|
|
41
|
+
# Escape URLs to prevent Mermaid from interpreting them as markdown links
|
|
42
|
+
safe_title = escape_label(self.title)
|
|
43
|
+
return f'("{safe_title}")'
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def id(self):
|
|
47
|
+
return re.sub(r'[:\/\.#()]', '_', urllib.parse.unquote(str(self.url)).strip('/'))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MermaidLiteral(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
|
|
51
|
+
"""{self.id}[["{self.title}"]]"""
|
|
52
|
+
|
|
53
|
+
literal: Literal
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def title(self) -> str:
|
|
57
|
+
raw_title = str(self.literal) or 'EMPTY'
|
|
58
|
+
# Replace quotes with safer characters for Mermaid
|
|
59
|
+
return raw_title.replace('"', '"').replace("'", "'")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def id(self) -> str:
|
|
63
|
+
value_hash = hashlib.md5(str(self.literal.value).encode()).hexdigest()
|
|
64
|
+
return f'Literal-{value_hash}'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MermaidBlankNode(Documented, BaseModel, arbitrary_types_allowed=True):
|
|
68
|
+
"""{self.id}({self.escaped_title})"""
|
|
69
|
+
|
|
70
|
+
node: BNode
|
|
71
|
+
title: str
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def id(self) -> str:
|
|
75
|
+
return self.node.replace('_:', '')
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def escaped_title(self) -> str:
|
|
79
|
+
return self.title
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MermaidEdge(Documented, BaseModel, arbitrary_types_allowed=True):
|
|
83
|
+
"""
|
|
84
|
+
{self.source.id} --- {self.id}(["{self.escaped_title}"])--> {self.target.id}
|
|
85
|
+
click {self.id} "{self.predicate}"
|
|
86
|
+
class {self.id} predicate
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
source: 'MermaidURINode | MermaidBlankNode | MermaidSubgraph'
|
|
90
|
+
target: 'MermaidURINode | MermaidLiteral | MermaidBlankNode | MermaidSubgraph'
|
|
91
|
+
predicate: URIRef
|
|
92
|
+
title: str
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def id(self) -> str:
|
|
96
|
+
return hashlib.md5(f'{self.source.id}{self.predicate}{self.target.id}'.encode()).hexdigest()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def nodes(self):
|
|
100
|
+
return [self.source, self.target]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def escaped_title(self) -> str:
|
|
104
|
+
# Escape URLs to prevent Mermaid from interpreting them as markdown links
|
|
105
|
+
return escape_label(self.title)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
MermaidScalar = MermaidLiteral | MermaidBlankNode | MermaidURINode | MermaidEdge
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MermaidSubgraph(Documented, BaseModel, arbitrary_types_allowed=True, frozen=True):
|
|
112
|
+
"""
|
|
113
|
+
subgraph {self.id}["{self.escaped_title}"]
|
|
114
|
+
direction {self.direction}
|
|
115
|
+
{self.formatted_body}
|
|
116
|
+
end
|
|
117
|
+
"""
|
|
118
|
+
children: list[MermaidScalar]
|
|
119
|
+
uri: NotLiteralNode
|
|
120
|
+
title: str
|
|
121
|
+
direction: Direction = Direction.LR
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def id(self):
|
|
125
|
+
uri_hash = hashlib.md5(str(self.uri).encode()).hexdigest()
|
|
126
|
+
return f'subgraph_{uri_hash}'
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def escaped_title(self) -> str:
|
|
130
|
+
"""Escape the subgraph title to prevent markdown link parsing."""
|
|
131
|
+
return escape_label(self.title)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def formatted_body(self):
|
|
135
|
+
return textwrap.indent(
|
|
136
|
+
'\n'.join(map(str, self.children)),
|
|
137
|
+
prefix=' ',
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Diagram(Documented, BaseModel):
|
|
142
|
+
"""
|
|
143
|
+
graph {self.direction}
|
|
144
|
+
{self.formatted_body}
|
|
145
|
+
classDef predicate fill:none,stroke:none,stroke-width:0px;
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
children: list[MermaidScalar | MermaidSubgraph]
|
|
149
|
+
direction: Direction = Direction.LR
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def formatted_body(self):
|
|
153
|
+
return textwrap.indent(
|
|
154
|
+
'\n'.join(map(str, self.children)),
|
|
155
|
+
prefix=' ',
|
|
156
|
+
)
|
iolanta/namespaces.py
CHANGED
|
@@ -3,7 +3,7 @@ import rdflib
|
|
|
3
3
|
LOCAL = rdflib.Namespace('local:')
|
|
4
4
|
IOLANTA = rdflib.Namespace('https://iolanta.tech/')
|
|
5
5
|
DATATYPES = rdflib.Namespace('https://iolanta.tech/datatypes/')
|
|
6
|
-
|
|
6
|
+
IOLANTA_FACETS = rdflib.Namespace('pkg:pypi/iolanta#')
|
|
7
7
|
NP = rdflib.Namespace('https://www.nanopub.org/nschema#')
|
|
8
8
|
RDFG = rdflib.Namespace('https://www.w3.org/2004/03/trix/rdfg-1/')
|
|
9
9
|
SDO = rdflib.SDO
|
iolanta/resolvers/base.py
CHANGED
|
@@ -8,6 +8,7 @@ from iolanta.facets.facet import Facet
|
|
|
8
8
|
|
|
9
9
|
class Resolver:
|
|
10
10
|
"""Resolve facet IRIs into classes."""
|
|
11
|
+
|
|
11
12
|
@abstractmethod
|
|
12
|
-
def
|
|
13
|
+
def resolve(self, uri: URIRef) -> Type[Facet]:
|
|
13
14
|
"""Find a resolver by IRI."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from rdflib import URIRef
|
|
4
|
+
from yarl import URL
|
|
5
|
+
|
|
6
|
+
from iolanta import Facet
|
|
7
|
+
from iolanta.resolvers.base import Resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SchemeDispatchResolver(Resolver):
|
|
11
|
+
"""
|
|
12
|
+
A resolver that dispatches to other resolvers based on URI scheme.
|
|
13
|
+
|
|
14
|
+
For example, 'pkg:' URIs are handled by PyPIResolver, while
|
|
15
|
+
'python:' URIs are handled by PythonImportResolver.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, **resolver_by_scheme: Type[Resolver]):
|
|
19
|
+
"""
|
|
20
|
+
Initialize with a mapping of URI schemes to resolver classes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
**resolver_by_scheme: Mapping of URI schemes to resolver classes.
|
|
24
|
+
For example: python=PythonImportResolver.
|
|
25
|
+
"""
|
|
26
|
+
self.resolver_by_scheme = resolver_by_scheme
|
|
27
|
+
|
|
28
|
+
def resolve(self, uri: URIRef) -> Type[Facet]:
|
|
29
|
+
"""
|
|
30
|
+
Find a resolver by IRI, dispatching to the appropriate scheme handler.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
uri: The URI to resolve.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Facet class resolved by the appropriate scheme handler.
|
|
37
|
+
"""
|
|
38
|
+
url = URL(uri)
|
|
39
|
+
resolver_class = self.resolver_by_scheme[url.scheme]
|
|
40
|
+
resolver = resolver_class()
|
|
41
|
+
return resolver.resolve(uri)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from typing import Type
|
|
6
|
+
|
|
7
|
+
from packageurl import PackageURL
|
|
8
|
+
from rdflib import URIRef
|
|
9
|
+
|
|
10
|
+
from iolanta import Facet
|
|
11
|
+
from iolanta.resolvers.base import Resolver
|
|
12
|
+
|
|
13
|
+
ENTRY_POINT_GROUP = 'iolanta.facets'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _norm(name: str) -> str:
|
|
17
|
+
"""Normalize package name by replacing common separators with dashes."""
|
|
18
|
+
return re.sub('[-_.]+', '-', name).lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MissingFacetError(RuntimeError):
|
|
22
|
+
"""Raised when a required facet is not found in the installed packages."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PyPIResolver(Resolver):
|
|
26
|
+
"""Resolve facet IRIs into classes from PyPI packages."""
|
|
27
|
+
|
|
28
|
+
def resolve(self, uri: URIRef) -> Type[Facet]:
|
|
29
|
+
"""Find and load a Facet class from a PyPI package."""
|
|
30
|
+
dist_name, facet_name = self._get_package_info(uri)
|
|
31
|
+
dist = self._get_distribution(dist_name, facet_name)
|
|
32
|
+
entry_point = self._find_facet_entry_point(dist, facet_name)
|
|
33
|
+
facet_class = entry_point.load()
|
|
34
|
+
self._validate_facet_class(
|
|
35
|
+
facet_class=facet_class,
|
|
36
|
+
facet_name=facet_name,
|
|
37
|
+
dist_name=dist.metadata['Name'],
|
|
38
|
+
)
|
|
39
|
+
return facet_class
|
|
40
|
+
|
|
41
|
+
def _get_package_info(self, uri: URIRef) -> tuple[str, str]:
|
|
42
|
+
"""Extract and validate package information from URI."""
|
|
43
|
+
package_url = PackageURL.from_string(str(uri))
|
|
44
|
+
if package_url.type != 'pypi':
|
|
45
|
+
raise NotImplementedError(f'{package_url.type} is not supported')
|
|
46
|
+
|
|
47
|
+
dist_name = _norm(package_url.name or '')
|
|
48
|
+
facet_name = package_url.subpath
|
|
49
|
+
|
|
50
|
+
if not dist_name or not facet_name:
|
|
51
|
+
raise ValueError('PURL must be pkg:pypi/<dist>#<FacetName>')
|
|
52
|
+
|
|
53
|
+
return dist_name, facet_name
|
|
54
|
+
|
|
55
|
+
def _get_distribution(
|
|
56
|
+
self,
|
|
57
|
+
dist_name: str,
|
|
58
|
+
facet_name: str,
|
|
59
|
+
) -> metadata.Distribution:
|
|
60
|
+
"""Get package distribution and verify it exists."""
|
|
61
|
+
try:
|
|
62
|
+
return metadata.distribution(dist_name)
|
|
63
|
+
except metadata.PackageNotFoundError as exc:
|
|
64
|
+
raise MissingFacetError(
|
|
65
|
+
f'This page requires `{dist_name}` (facet `{facet_name}`). '
|
|
66
|
+
f'Install with: pip install "{dist_name}"',
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
def _find_facet_entry_point(
|
|
70
|
+
self,
|
|
71
|
+
dist: metadata.Distribution,
|
|
72
|
+
facet_name: str,
|
|
73
|
+
) -> metadata.EntryPoint:
|
|
74
|
+
"""Find the facet entry point in the distribution."""
|
|
75
|
+
matching_entry_points = [
|
|
76
|
+
entry_point
|
|
77
|
+
for entry_point in dist.entry_points
|
|
78
|
+
if (
|
|
79
|
+
entry_point.group == ENTRY_POINT_GROUP
|
|
80
|
+
and entry_point.name == facet_name
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
if not matching_entry_points:
|
|
85
|
+
dist_name = dist.metadata['Name']
|
|
86
|
+
msg = (
|
|
87
|
+
f'Facet `{facet_name}` not found in `{dist_name}` '
|
|
88
|
+
f'{dist.version}. Ensure it exposes entry point '
|
|
89
|
+
f'`{ENTRY_POINT_GROUP}` named `{facet_name}`.'
|
|
90
|
+
)
|
|
91
|
+
raise MissingFacetError(msg)
|
|
92
|
+
|
|
93
|
+
return matching_entry_points[0]
|
|
94
|
+
|
|
95
|
+
def _validate_facet_class(
|
|
96
|
+
self,
|
|
97
|
+
facet_class: Type[Facet],
|
|
98
|
+
facet_name: str,
|
|
99
|
+
dist_name: str,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Validate that the loaded class is a Facet subclass."""
|
|
102
|
+
if not issubclass(facet_class, Facet):
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f'Entry point `{facet_name}` in `{dist_name}` '
|
|
105
|
+
'is not a subclass of iolanta.Facet',
|
|
106
|
+
)
|