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.
Files changed (58) hide show
  1. iolanta/cli/main.py +45 -25
  2. iolanta/data/graph-triples.yamlld +2 -2
  3. iolanta/data/textual-browser.yaml +23 -22
  4. iolanta/declension/__init__.py +0 -0
  5. iolanta/declension/data/declension.yamlld +39 -0
  6. iolanta/declension/facet.py +44 -0
  7. iolanta/declension/sparql/declension.sparql +8 -0
  8. iolanta/facets/cli/record.py +2 -2
  9. iolanta/facets/facet.py +1 -22
  10. iolanta/facets/foaf_person_title/facet.py +2 -2
  11. iolanta/facets/generic/bool_literal.py +3 -3
  12. iolanta/facets/generic/date_literal.py +1 -1
  13. iolanta/facets/generic/default.py +2 -2
  14. iolanta/facets/html/code_literal.py +3 -3
  15. iolanta/facets/icon.py +1 -1
  16. iolanta/facets/locator.py +1 -4
  17. iolanta/facets/qname.py +2 -2
  18. iolanta/facets/textual_browser/app.py +7 -3
  19. iolanta/facets/textual_browser/facet.py +1 -1
  20. iolanta/facets/textual_browser/page_switcher.py +13 -18
  21. iolanta/facets/textual_class/facets.py +3 -3
  22. iolanta/facets/textual_class/sparql/instances.sparql +4 -1
  23. iolanta/facets/textual_default/facets.py +5 -5
  24. iolanta/facets/textual_default/widgets.py +1 -1
  25. iolanta/facets/textual_graph/facets.py +3 -3
  26. iolanta/facets/textual_graph_triples.py +1 -1
  27. iolanta/facets/textual_link/facet.py +4 -4
  28. iolanta/facets/textual_nanopublication/facet.py +1 -1
  29. iolanta/facets/textual_no_facet_found.py +3 -1
  30. iolanta/facets/textual_ontology/facets.py +3 -3
  31. iolanta/facets/textual_provenance/facets.py +1 -1
  32. iolanta/facets/title/facets.py +3 -5
  33. iolanta/facets/title/sparql/title.sparql +5 -0
  34. iolanta/facets/wikibase_statement_title/facets.py +2 -2
  35. iolanta/iolanta.py +35 -68
  36. iolanta/labeled_triple_set/__init__.py +0 -0
  37. iolanta/labeled_triple_set/data/labeled_triple_set.yamlld +42 -0
  38. iolanta/labeled_triple_set/labeled_triple_set.py +137 -0
  39. iolanta/labeled_triple_set/sparql/triples.sparql +5 -0
  40. iolanta/mermaid/__init__.py +0 -0
  41. iolanta/mermaid/facet.py +127 -0
  42. iolanta/mermaid/mermaid.yamlld +42 -0
  43. iolanta/mermaid/models.py +156 -0
  44. iolanta/mermaid/sparql/graph.sparql +5 -0
  45. iolanta/mermaid/sparql/subgraphs.sparql +3 -0
  46. iolanta/namespaces.py +1 -1
  47. iolanta/resolvers/base.py +2 -1
  48. iolanta/resolvers/dispatch.py +41 -0
  49. iolanta/resolvers/pypi.py +106 -0
  50. iolanta/resolvers/python_import.py +9 -32
  51. iolanta/sparqlspace/processor.py +4 -1
  52. {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/METADATA +7 -8
  53. {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/RECORD +55 -41
  54. {iolanta-2.0.8.dist-info → iolanta-2.1.4.dist-info}/WHEEL +1 -1
  55. iolanta-2.1.4.dist-info/entry_points.txt +33 -0
  56. iolanta/data/cli.yaml +0 -30
  57. iolanta/data/html.yaml +0 -15
  58. 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()
@@ -0,0 +1,5 @@
1
+ SELECT * WHERE {
2
+ GRAPH $graph {
3
+ ?subject ?predicate ?object .
4
+ }
5
+ }
File without changes
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ SELECT * WHERE {
2
+ GRAPH $this {
3
+ ?s ?p ?o .
4
+ }
5
+ }
@@ -0,0 +1,3 @@
1
+ SELECT ?subgraph WHERE {
2
+ $this iolanta:has-sub-graph ?subgraph .
3
+ }
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
- PYTHON = rdflib.Namespace('python://')
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 __getitem__(self, facet_iri: URIRef) -> Type[Facet]:
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
+ )