Graphinate 0.12.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.
graphinate/modeling.py ADDED
@@ -0,0 +1,337 @@
1
+ import inspect
2
+ import itertools
3
+ from collections import defaultdict, namedtuple
4
+ from collections.abc import Callable, Iterable, Mapping
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+ from typing import Any, Union
8
+
9
+ from .typing import Edge, Element, Extractor, Items, Node, NodeTypeAbsoluteId, UniverseNode
10
+
11
+
12
+ class GraphModelError(Exception):
13
+ pass
14
+
15
+
16
+ def element(element_type: str | None, field_names: Iterable[str] | None = None) -> Callable[[], Element]:
17
+ """Graph Element Supplier Callable
18
+
19
+ Args:
20
+ element_type:
21
+ field_names:
22
+
23
+ Returns:
24
+ Element Supplier Callable
25
+ """
26
+ return namedtuple(element_type, field_names) if element_type and field_names else tuple
27
+
28
+
29
+ def extractor(obj: Any, key: Extractor | None = None) -> str | None:
30
+ """Extract data item from Element
31
+
32
+ Args:
33
+ obj:
34
+ key:
35
+
36
+ Returns:
37
+ Element data item
38
+ """
39
+ if key is None:
40
+ return obj
41
+
42
+ if callable(key):
43
+ return key(obj)
44
+
45
+ if isinstance(obj, Mapping) and isinstance(key, str):
46
+ return obj.get(key, key)
47
+
48
+ return key
49
+
50
+
51
+ def elements(iterable: Iterable[Any],
52
+ element_type: Extractor | None = None,
53
+ **getters: Extractor) -> Iterable[Element]:
54
+ """Abstract Generator of Graph elements (nodes or edges)
55
+
56
+ Args:
57
+ iterable: source of payload
58
+ element_type: Optional[Extractor] source of type of the element. Defaults to Element Type name.
59
+ getters: Extractor node field sources
60
+
61
+ Returns:
62
+ Iterable of Elements.
63
+ """
64
+ for item in iterable:
65
+ _type = element_type(item) if element_type and callable(element_type) else element_type
66
+ if not _type.isidentifier():
67
+ raise ValueError(f"Invalid Type: {_type}. Must be a valid Python identifier.")
68
+
69
+ create_element = element(_type, getters.keys())
70
+ kwargs = {k: extractor(item, v) for k, v in getters.items()}
71
+ yield create_element(**kwargs)
72
+
73
+
74
+ class Multiplicity(Enum):
75
+ ADD = auto()
76
+ ALL = auto()
77
+ FIRST = auto()
78
+ LAST = auto()
79
+
80
+
81
+ @dataclass
82
+ class NodeModel:
83
+ """Represents a Node Model
84
+
85
+ Args:
86
+ type: the type of the Node.
87
+ parent_type: the type of the node's parent. Defaults to UniverseNode.
88
+ parameters: parameters of the Node. Defaults to None.
89
+ label: label source. Defaults to None.
90
+ uniqueness: is the Node universally unique. Defaults to True.
91
+ multiplicity: Multiplicity of the Node. Defaults to ALL.
92
+ generator: Nodes generator method. Defaults to None.
93
+
94
+ Properties:
95
+ absolute_id: return the NodeModel absolute_id.
96
+ """
97
+
98
+ type: str
99
+ parent_type: str | UniverseNode | None = UniverseNode
100
+ parameters: set[str] | None = None
101
+ label: Callable[[Any], str | None] = None
102
+ uniqueness: bool = True
103
+ multiplicity: Multiplicity = Multiplicity.ALL
104
+ generator: Callable[[], Iterable[Node]] | None = None
105
+
106
+ @property
107
+ def absolute_id(self) -> NodeTypeAbsoluteId:
108
+ return self.parent_type, self.type
109
+
110
+
111
+ class GraphModel:
112
+ """A Graph Model
113
+
114
+ Used to declaratively register Edge and/or Node data supplier functions by using
115
+ decorators.
116
+
117
+ Args:
118
+ name: the archetype name for Graphs generated based on the GraphModel.
119
+ """
120
+
121
+ def __init__(self, name: str):
122
+ self.name: str = name
123
+ self._node_models: dict[NodeTypeAbsoluteId, list[NodeModel]] = defaultdict(list)
124
+ self._node_children: dict[str, list[NodeModel]] = defaultdict(list)
125
+ self._edge_generators: dict[str, list[Callable[[], Iterable[Edge]]]] = defaultdict(list)
126
+ self._networkx_graph = None
127
+
128
+ def __add__(self, other: 'GraphModel') -> 'GraphModel':
129
+ graph_model = GraphModel(name=f"{self.name} + {other.name}")
130
+ for m in (self, other):
131
+ for k, v in m._node_models.items():
132
+ graph_model._node_models[k].extend(v)
133
+
134
+ for k, v in m._node_children.items():
135
+ graph_model._node_children[k].extend(v)
136
+
137
+ for k, v in m._edge_generators.items():
138
+ graph_model._edge_generators[k].extend(v)
139
+
140
+ return graph_model
141
+
142
+ @property
143
+ def node_models(self) -> dict[NodeTypeAbsoluteId, list[NodeModel]]:
144
+ """
145
+ Returns:
146
+ NodeModel for Node Types. Key values are NodeTypeAbsoluteId.
147
+ """
148
+ return self._node_models
149
+
150
+ @property
151
+ def edge_generators(self):
152
+ """
153
+ Returns:
154
+ Edge generator functions for Edge Types
155
+ """
156
+ return self._edge_generators
157
+
158
+ @property
159
+ def node_types(self) -> set[str]:
160
+ """
161
+ Returns:
162
+ Node Types
163
+ """
164
+ return {v.type for v in itertools.chain.from_iterable(self._node_models.values())}
165
+
166
+ def node_children_types(self, _type: str = UniverseNode) -> dict[str, list[str]]:
167
+ """Children Node Types for given input Node Type
168
+
169
+ Args:
170
+ _type: Node Type. Default value is UNIVERSE_NODE.
171
+
172
+ Returns:
173
+ List of children Node Types.
174
+ """
175
+ return {k: v for k, v in self._node_children.items() if k == _type}
176
+
177
+ @staticmethod
178
+ def _validate_type(node_type: str):
179
+ if not callable(node_type) and not node_type.isidentifier():
180
+ raise ValueError(f"Invalid Type: {node_type}. Must be a valid Python identifier.")
181
+
182
+ def _validate_node_parameters(self, parameters: list[str]):
183
+ node_types = self.node_types
184
+ if not all(p.endswith('_id') and p == p.lower() and p[:-3] in node_types for p in parameters):
185
+ msg = ("Illegal Arguments. Argument should conform to the following rules: "
186
+ "1) lowercase "
187
+ "2) end with '_id' "
188
+ "3) start with value that exists as registered node type")
189
+
190
+ raise GraphModelError(msg)
191
+
192
+ def node(self,
193
+ type_: Extractor | None = None,
194
+ parent_type: str | None = UniverseNode,
195
+ key: Extractor | None = None,
196
+ value: Extractor | None = None,
197
+ label: Extractor | None = None,
198
+ unique: bool = True,
199
+ multiplicity: Multiplicity = Multiplicity.ALL) -> Callable[[Items], None]:
200
+ """Decorator to Register a Generator of node payloads as a source for Graph Nodes.
201
+ It creates a NodeModel object.
202
+
203
+ Args:
204
+ type_: Optional source for the Node Type. Defaults to use Generator function
205
+ name as the Node Type.
206
+ parent_type: Optional parent Node Type. Defaults to UNIVERSE_NODE
207
+
208
+ key: Optional source for Node IDs. Defaults to use the complete Node payload
209
+ as Node ID.
210
+ value: Optional source for Node value field. Defaults to use the complete
211
+ Node payload as Node ID.
212
+ label: Optional source for Node label field. Defaults to use a 'str'
213
+ representation of the complete Node payload.
214
+ unique: is the Node universally unique. Defaults to True.
215
+ multiplicity: Multiplicity of the Node. Defaults to ALL.
216
+
217
+ Returns:
218
+ None
219
+ """
220
+
221
+ def register_node(f: Items):
222
+ node_type = type_ or f.__name__
223
+ self._validate_type(node_type)
224
+
225
+ model_type = f.__name__ if callable(node_type) else node_type
226
+
227
+ def node_generator(**kwargs: Any) -> Iterable[Node]:
228
+ yield from elements(f(**kwargs), node_type, key=key, value=value)
229
+
230
+ parameters = inspect.getfullargspec(f).args
231
+ node_model = NodeModel(type=model_type,
232
+ parent_type=parent_type,
233
+ parameters=set(parameters),
234
+ label=label,
235
+ uniqueness=unique,
236
+ multiplicity=multiplicity,
237
+ generator=node_generator)
238
+ self._node_models[node_model.absolute_id].append(node_model)
239
+ self._node_children[parent_type].append(model_type)
240
+
241
+ self._validate_node_parameters(parameters)
242
+
243
+ return register_node
244
+
245
+ def edge(self,
246
+ type_: Extractor | None = None,
247
+ source: Extractor = 'source',
248
+ target: Extractor = 'target',
249
+ label: Extractor | None = str,
250
+ value: Extractor | None = None,
251
+ weight: Union[float, Callable[[Any], float]] = 1.0,
252
+ ) -> Callable[[Items], None]:
253
+ """Decorator to Register a generator of edge payloads as a source of Graph Edges.
254
+ It creates an Edge generator function.
255
+
256
+ Args:
257
+ type_: Optional source for the Edge Type. Defaults to use Generator function
258
+ name as the Edge Type.
259
+ source: Source for edge source Node ID.
260
+ target: Source for edge target Node ID.
261
+ label: Source for edge label.
262
+ value: Source for edge value.
263
+ weight: Source for edge weight.
264
+
265
+ Returns:
266
+ None.
267
+ """
268
+
269
+ def register_edge(f: Items):
270
+ edge_type = type_ or f.__name__
271
+ self._validate_type(edge_type)
272
+
273
+ model_type = f.__name__ if callable(edge_type) else edge_type
274
+
275
+ getters = {
276
+ 'source': source,
277
+ 'target': target,
278
+ 'label': label,
279
+ 'type': edge_type,
280
+ 'value': value,
281
+ 'weight': weight
282
+ }
283
+
284
+ def edge_generator(**kwargs: Any) -> Iterable[Edge]:
285
+ yield from elements(f(**kwargs), edge_type, **getters)
286
+
287
+ self._edge_generators[model_type].append(edge_generator)
288
+
289
+ return register_edge
290
+
291
+ def rectify(self, _type: Extractor | None = None,
292
+ parent_type: str | None = UniverseNode,
293
+ key: Extractor | None = None,
294
+ value: Extractor | None = None,
295
+ label: Extractor | None = None):
296
+ """
297
+ Rectify the model.
298
+ Add a default NodeModel in case of having just edge supplier/s and no node supplier/s.
299
+
300
+ Args:
301
+ _type
302
+ parent_type
303
+ key
304
+ value
305
+ label
306
+
307
+ Returns:
308
+ None
309
+ """
310
+ if self._edge_generators and not self._node_models:
311
+ @self.node(
312
+ type_=_type or 'node',
313
+ parent_type=parent_type or 'node',
314
+ unique=True,
315
+ key=key,
316
+ value=value,
317
+ label=label or str
318
+ )
319
+ def node(): # pragma: no cover
320
+ return
321
+ yield
322
+
323
+
324
+ def model(name: str):
325
+ """
326
+ Create a graph model
327
+
328
+ Args:
329
+ name: model name
330
+
331
+ Returns:
332
+ GraphModel
333
+ """
334
+ return GraphModel(name=name)
335
+
336
+
337
+ __all__ = ('GraphModel', 'Multiplicity', 'model')
@@ -0,0 +1,5 @@
1
+ import networkx_mermaid.formatters as mermaid
2
+
3
+ from . import graphql, matplotlib
4
+
5
+ __all__ = ('graphql', 'matplotlib', 'mermaid')
@@ -0,0 +1,111 @@
1
+ import contextlib
2
+ import webbrowser
3
+ from typing import Any
4
+
5
+ import strawberry
6
+ from starlette.applications import Starlette
7
+ from starlette.requests import Request
8
+ from starlette.responses import RedirectResponse
9
+ from starlette.schemas import SchemaGenerator
10
+ from starlette.types import ASGIApp
11
+ from strawberry.asgi import GraphQL
12
+
13
+ from graphinate.server.starlette import routes
14
+
15
+ DEFAULT_PORT: int = 8072
16
+
17
+ GRAPHQL_ROUTE_PATH = "/graphql"
18
+
19
+
20
+ def _openapi_schema(request: Request) -> ASGIApp:
21
+ """
22
+ Generates an OpenAPI schema for the GraphQL API and other routes.
23
+
24
+ Args:
25
+ request (Request): The HTTP request object.
26
+
27
+ Returns:
28
+ ASGIApp: An OpenAPI response containing the schema for the specified routes.
29
+ """
30
+ schema_data = {
31
+ 'openapi': '3.0.0',
32
+ 'info': {'title': 'Graphinate API', 'version': '0.10.1'},
33
+ 'paths': {
34
+ '/graphql': {'get': {'responses': {200: {'description': 'GraphQL'}}}},
35
+ '/graphiql': {'get': {'responses': {200: {'description': 'GraphiQL UI.'}}}},
36
+ '/metrics': {'get': {'responses': {200: {'description': 'Prometheus metrics.'}}}},
37
+ '/viewer': {'get': {'responses': {200: {'description': '3D Force-Directed Graph Viewer'}}}},
38
+ '/voyager': {'get': {'responses': {200: {'description': 'Voyager GraphQL Schema Viewer'}}}}
39
+ }
40
+ }
41
+
42
+ schema = SchemaGenerator(schema_data)
43
+ return schema.OpenAPIResponse(request=request)
44
+
45
+
46
+ def _graphql_app(graphql_schema: strawberry.Schema) -> strawberry.asgi.GraphQL:
47
+ """
48
+ Creates a Strawberry GraphQL app with the provided schema.
49
+ Args:
50
+ graphql_schema:
51
+
52
+ Returns:
53
+ strawberry.asgi.GraphQL: The GraphQL app configured with the provided schema.
54
+ """
55
+ graphql_app = GraphQL(graphql_schema, graphql_ide='apollo-sandbox')
56
+ return graphql_app
57
+
58
+
59
+ def _starlette_app(graphql_app: strawberry.asgi.GraphQL | None = None,
60
+ port: int = DEFAULT_PORT,
61
+ **kwargs: Any) -> Starlette:
62
+ def open_url(endpoint):
63
+ webbrowser.open(f'http://localhost:{port}/{endpoint}')
64
+
65
+ @contextlib.asynccontextmanager
66
+ async def lifespan(app: Starlette): # pragma: no cover
67
+ if kwargs.get('browse'):
68
+ open_url('viewer')
69
+ yield
70
+
71
+ app = Starlette(
72
+ lifespan=lifespan,
73
+ routes=routes()
74
+ )
75
+
76
+ from starlette_prometheus import PrometheusMiddleware, metrics
77
+ app.add_middleware(PrometheusMiddleware)
78
+ app.add_route("/metrics", metrics)
79
+
80
+ if graphql_app:
81
+ app.add_route(GRAPHQL_ROUTE_PATH, graphql_app)
82
+ app.add_websocket_route(GRAPHQL_ROUTE_PATH, graphql_app)
83
+ app.add_route("/schema", route=_openapi_schema, include_in_schema=False)
84
+ app.add_route("/openapi.json", route=_openapi_schema, include_in_schema=False)
85
+
86
+ def redirect_to_viewer(request):
87
+ return RedirectResponse(url='/viewer')
88
+
89
+ app.add_route('/', redirect_to_viewer)
90
+
91
+ return app
92
+
93
+
94
+ def server(graphql_schema: strawberry.Schema, port: int = DEFAULT_PORT, **kwargs: Any):
95
+ """
96
+ Args:
97
+ graphql_schema: The Strawberry GraphQL schema.
98
+ port: The port number to run the server on. Defaults to 8072.
99
+
100
+ Returns:
101
+ """
102
+
103
+ graphql_app = _graphql_app(graphql_schema)
104
+
105
+ app = _starlette_app(graphql_app, port=port, **kwargs)
106
+
107
+ import uvicorn
108
+ uvicorn.run(app, host='0.0.0.0', port=port)
109
+
110
+
111
+ __all__ = ['server']
@@ -0,0 +1,82 @@
1
+ from typing import Any
2
+
3
+ import networkx as nx
4
+ from matplotlib import pyplot
5
+
6
+ from ..color import node_color_mapping
7
+
8
+
9
+ def draw(graph: nx.Graph,
10
+ with_node_labels: bool = True,
11
+ with_edge_labels: bool = False,
12
+ **kwargs: Any) -> None:
13
+ """
14
+ Draws the given networkx graph with optional node and edge labels.
15
+
16
+ Args:
17
+ graph (nx.Graph): The input graph to be drawn.
18
+ with_node_labels (bool): Whether to display node labels. Default is True.
19
+ with_edge_labels (bool): Whether to display edge labels. Default is False.
20
+
21
+ Returns:
22
+ None
23
+ """
24
+ pos = nx.planar_layout(graph) if nx.is_planar(graph) else None
25
+ pos = nx.spring_layout(graph, pos=pos) if pos else nx.spring_layout(graph)
26
+
27
+ draw_params = {}
28
+ if with_node_labels:
29
+ draw_params.update(
30
+ {
31
+ 'with_labels': True,
32
+ 'labels': nx.get_node_attributes(graph, 'label'),
33
+ 'font_size': 6,
34
+ 'font_color': 'blue',
35
+ # 'horizontalalignment':'left',
36
+ # 'verticalalignment':'bottom',
37
+ # 'bbox': {
38
+ # 'boxstyle': 'round',
39
+ # 'fc': (0.02, 0.02, 0.02),
40
+ # 'lw': 0,
41
+ # 'alpha': 0.15,
42
+ # 'path_effects': [patheffects.withStroke(linewidth=1, foreground="red")]
43
+ # }
44
+ }
45
+ )
46
+
47
+ node_color = list(node_color_mapping(graph).values())
48
+ nx.draw(graph, pos, node_color=node_color, **draw_params)
49
+ if with_edge_labels:
50
+ nx.draw_networkx_edge_labels(graph,
51
+ pos,
52
+ edge_labels=nx.get_edge_attributes(graph, 'label'),
53
+ font_color='red',
54
+ font_size=6)
55
+
56
+
57
+ def plot(graph: nx.Graph,
58
+ with_node_labels: bool = True,
59
+ with_edge_labels: bool = False,
60
+ **kwargs: Any) -> None:
61
+ """
62
+ Plots the given networkx graph with optional node and edge labels.
63
+
64
+ Args:
65
+ graph (nx.Graph): The input graph to be plotted.
66
+ with_node_labels (bool): Whether to display node labels. Default is True.
67
+ with_edge_labels (bool): Whether to display edge labels. Default is False.
68
+
69
+ Returns:
70
+ None
71
+ """
72
+ draw(graph, with_node_labels, with_edge_labels, **kwargs)
73
+
74
+ ax = pyplot.gca()
75
+ ax.margins(0.10)
76
+
77
+ fig = pyplot.gcf()
78
+ fig.suptitle(graph.name)
79
+ fig.tight_layout()
80
+
81
+ # pyplot.axis("off")
82
+ pyplot.show()
File without changes
@@ -0,0 +1,31 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from pathlib import Path
3
+
4
+ from starlette.routing import BaseRoute, Mount
5
+ from starlette.staticfiles import StaticFiles
6
+
7
+ from ..web import paths_mapping
8
+ from .views import favicon_route
9
+
10
+
11
+ def _mount_static_files(named_paths: Mapping[str, Path]) -> list[Mount]:
12
+ mounts = []
13
+ for name, path in named_paths.items():
14
+ if not name.startswith('__'):
15
+ index_file = path / 'index.html'
16
+ static_files = StaticFiles(directory=path, html=index_file.exists(), check_dir=True)
17
+ mount = Mount(path=f"/{name}", app=static_files, name=name)
18
+ mounts.append(mount)
19
+ return mounts
20
+
21
+
22
+ def routes() -> Sequence[BaseRoute]:
23
+ route_list: list[BaseRoute] = [
24
+ *_mount_static_files(paths_mapping),
25
+ favicon_route()
26
+ ]
27
+
28
+ return route_list
29
+
30
+
31
+ __all__ = ('routes',)
@@ -0,0 +1,17 @@
1
+ import functools
2
+
3
+ from starlette.requests import Request
4
+ from starlette.responses import FileResponse
5
+ from starlette.routing import Route
6
+
7
+ from ..web import get_static_path
8
+
9
+
10
+ def favicon(request: Request) -> FileResponse:
11
+ path = get_static_path('images/logo-128.png').absolute().as_posix()
12
+ return FileResponse(path)
13
+
14
+
15
+ @functools.cache
16
+ def favicon_route() -> Route:
17
+ return Route('/favicon.ico', endpoint=favicon, include_in_schema=False)
@@ -0,0 +1,25 @@
1
+ import inspect
2
+ import pathlib
3
+ from collections.abc import Generator, Mapping
4
+ from typing import Any
5
+
6
+
7
+ def current_file() -> pathlib.Path | None:
8
+ """Returns current file name"""
9
+ if (current_frame := inspect.currentframe()) and current_frame.f_back is not None:
10
+ return pathlib.Path(inspect.getfile(current_frame.f_back))
11
+ return None
12
+
13
+
14
+ def _current_file_peers() -> Generator[tuple[str, pathlib.Path], Any, None]:
15
+ _current_file = current_file()
16
+ if _current_file is not None:
17
+ for p in _current_file.parent.iterdir():
18
+ yield p.name, p
19
+
20
+
21
+ paths_mapping: Mapping[str, pathlib.Path] = dict(_current_file_peers())
22
+
23
+
24
+ def get_static_path(relative_path: str) -> pathlib.Path:
25
+ return paths_mapping['static'] / relative_path
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6
+
7
+ <title>Graphinate - OpanAPI Elements UI</title>
8
+ <!-- Embed elements Elements via Web Component -->
9
+ <script src="https://unpkg.com/@stoplight/elements/web-components.min.js"
10
+ integrity="sha384-yR4p7dGVb43Z+zDGOg8xyIj71nVRZvN/IpUbptwjAtxcq+IcVQ+mNMiL7ppakjKc"></script>
11
+ <link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css"
12
+ integrity="sha384-fgRmJCkCe8Qyv2rSIDjIvkm779dv+HZbSa/ptwSeufq8qJZo/xsi79N8Rb6xTIMO">
13
+ </head>
14
+ <body>
15
+
16
+ <elements-api
17
+ apiDescriptionUrl="/schema"
18
+ router="hash"
19
+ layout="sidebar"
20
+ />
21
+
22
+ </body>
23
+ </html>