Passed
Pull Request — main (#32)
by
unknown
01:42
created

graphinate.modeling.GraphModel.edge_generators()   A

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 1
nop 1
1
import inspect
2
import itertools
3
from functools import lru_cache
4
from collections import defaultdict, namedtuple
5
from collections.abc import Callable, Iterable, Mapping
6
from dataclasses import dataclass
7
from enum import Enum, auto
8
from types import MappingProxyType
9
from typing import Any, Union
10
11
from .typing import Edge, Element, Extractor, Items, Node, NodeTypeAbsoluteId, UniverseNode
12
13
14
class GraphModelError(Exception):
15
    pass
16
17
18
@lru_cache(maxsize=None)
19
def _get_namedtuple(element_type: str, field_names: tuple[str]):
20
    return namedtuple(element_type, field_names)
21
22
23
def element(element_type: str | None, field_names: Iterable[str] | None = None) -> Callable[[], Element]:
24
    """Graph Element Supplier Callable
25
26
    Args:
27
        element_type:
28
        field_names:
29
30
    Returns:
31
        Element Supplier Callable
32
    """
33
    if element_type and field_names:
34
        return _get_namedtuple(element_type, tuple(field_names))
35
    return tuple
36
37
38
def extractor(obj: Any, key: Extractor | None = None) -> str | None:
39
    """Extract data item from Element
40
41
    Args:
42
        obj:
43
        key:
44
45
    Returns:
46
        Element data item
47
    """
48
    if key is None:
49
        return obj
50
51
    if callable(key):
52
        return key(obj)
53
54
    if isinstance(obj, Mapping) and isinstance(key, str):
55
        return obj.get(key, key)
56
57
    return key
58
59
60
def elements(iterable: Iterable[Any],
61
             element_type: Extractor | None = None,
62
             **getters: Extractor) -> Iterable[Element]:
63
    """Abstract Generator of Graph elements (nodes or edges)
64
65
    Args:
66
        iterable: source of payload
67
        element_type: Optional[Extractor] source of type of the element. Defaults to Element Type name.
68
        getters: Extractor node field sources
69
70
    Returns:
71
        Iterable of Elements.
72
    """
73
    for item in iterable:
74
        _type = element_type(item) if element_type and callable(element_type) else element_type
75
        if not _type.isidentifier():
76
            raise ValueError(f"Invalid Type: {_type}. Must be a valid Python identifier.")
77
78
        create_element = element(_type, getters.keys())
79
        kwargs = {k: extractor(item, v) for k, v in getters.items()}
80
        yield create_element(**kwargs)
81
82
83
class Multiplicity(Enum):
84
    ADD = auto()
85
    ALL = auto()
86
    FIRST = auto()
87
    LAST = auto()
88
89
90
@dataclass
91
class NodeModel:
92
    """Represents a Node Model
93
94
    Args:
95
        type: the type of the Node.
96
        parent_type: the type of the node's parent. Defaults to UniverseNode.
97
        parameters: parameters of the Node. Defaults to None.
98
        label: label source. Defaults to None.
99
        uniqueness: is the Node universally unique. Defaults to True.
100
        multiplicity: Multiplicity of the Node. Defaults to ALL.
101
        generator: Nodes generator method. Defaults to None.
102
103
    Properties:
104
        absolute_id: return the NodeModel absolute_id.
105
    """
106
107
    type: str
108
    parent_type: str | UniverseNode | None = UniverseNode
109
    parameters: set[str] | None = None
110
    label: Callable[[Any], str | None] = None
111
    uniqueness: bool = True
112
    multiplicity: Multiplicity = Multiplicity.ALL
113
    generator: Callable[[], Iterable[Node]] | None = None
114
115
    @property
116
    def absolute_id(self) -> NodeTypeAbsoluteId:
117
        return self.parent_type, self.type
118
119
120
class GraphModel:
121
    """A Graph Model
122
123
    Used to declaratively register Edge and/or Node data supplier functions by using
124
    decorators.
125
126
    Args:
127
        name: the archetype name for Graphs generated based on the GraphModel.
128
    """
129
130
    def __init__(self, name: str):
131
        self.name: str = name
132
        self._node_models: dict[NodeTypeAbsoluteId, list[NodeModel]] = defaultdict(list)
133
        self._node_children: dict[str, list[NodeModel]] = defaultdict(list)
134
        self._edge_generators: dict[str, list[Callable[[], Iterable[Edge]]]] = defaultdict(list)
135
        self._networkx_graph = None
136
137
    def __add__(self, other: 'GraphModel') -> 'GraphModel':
138
        graph_model = GraphModel(name=f"{self.name} + {other.name}")
139
        for m in (self, other):
140
            for k, v in m._node_models.items():
141
                graph_model._node_models[k].extend(v)
142
143
            for k, v in m._node_children.items():
144
                graph_model._node_children[k].extend(v)
145
146
            for k, v in m._edge_generators.items():
147
                graph_model._edge_generators[k].extend(v)
148
149
        return graph_model
150
151
    @property
152
    def node_models(self) -> Mapping[NodeTypeAbsoluteId, list[NodeModel]]:
153
        """
154
        Returns:
155
            NodeModel for Node Types. Key values are NodeTypeAbsoluteId.
156
        """
157
        return MappingProxyType(self._node_models)
158
159
    @property
160
    def edge_generators(self) -> Mapping[str, list[Callable[[], Iterable[Edge]]]]:
161
        """
162
        Returns:
163
            Edge generator functions for Edge Types
164
        """
165
        return MappingProxyType(self._edge_generators)
166
167
    @property
168
    def node_types(self) -> set[str]:
169
        """
170
        Returns:
171
            Node Types
172
        """
173
        return {v.type for v in itertools.chain.from_iterable(self._node_models.values())}
174
175
    def node_children_types(self, _type: str = UniverseNode) -> Mapping[str, list[str]]:
176
        """Children Node Types for given input Node Type
177
178
        Args:
179
            _type:  Node Type. Default value is UNIVERSE_NODE.
180
181
        Returns:
182
            List of children Node Types.
183
        """
184
        return MappingProxyType({k: v for k, v in self._node_children.items() if k == _type})
185
186
    @staticmethod
187
    def _validate_type(node_type: str):
188
        if not callable(node_type) and not node_type.isidentifier():
189
            raise ValueError(f"Invalid Type: {node_type}. Must be a valid Python identifier.")
190
191
    def _validate_node_parameters(self, parameters: list[str]):
192
        node_types = self.node_types
193
        if not all(p.endswith('_id') and p == p.lower() and p[:-3] in node_types for p in parameters):
194
            msg = ("Illegal Arguments. Argument should conform to the following rules: "
195
                   "1) lowercase "
196
                   "2) end with '_id' "
197
                   "3) start with value that exists as registered node type")
198
199
            raise GraphModelError(msg)
200
201
    def node(self,
202
             type_: Extractor | None = None,
203
             parent_type: str | None = UniverseNode,
204
             key: Extractor | None = None,
205
             value: Extractor | None = None,
206
             label: Extractor | None = None,
207
             unique: bool = True,
208
             multiplicity: Multiplicity = Multiplicity.ALL) -> Callable[[Items], None]:
209
        """Decorator to Register a Generator of node payloads as a source for Graph Nodes.
210
        It creates a NodeModel object.
211
212
        Args:
213
            type_: Optional source for the Node Type. Defaults to use Generator function
214
                   name as the Node Type.
215
            parent_type: Optional parent Node Type. Defaults to UNIVERSE_NODE
216
217
            key: Optional source for Node IDs. Defaults to use the complete Node payload
218
                 as Node ID.
219
            value: Optional source for Node value field. Defaults to use the complete
220
                   Node payload as Node ID.
221
            label: Optional source for Node label field. Defaults to use a 'str'
222
                   representation of the complete Node payload.
223
            unique: is the Node universally unique. Defaults to True.
224
            multiplicity: Multiplicity of the Node. Defaults to ALL.
225
226
        Generator Function Signature:
227
            The decorated generator function may accept arguments to receive context from parent nodes.
228
            These arguments MUST conform to the following strict naming convention:
229
            1. The argument name must be lowercase.
230
            2. The argument name must end with '_id'.
231
            3. The prefix (before '_id') must match an existing registered Node Type.
232
233
            Example:
234
                If you have a parent node type 'user', your child node generator can accept 'user_id'.
235
236
                @model.node(parent_type='user')
237
                def get_posts(user_id): ...
238
239
            Note: Arbitrary arguments (e.g., configuration flags) are currently NOT supported.
240
241
        Returns:
242
            None
243
        """
244
245
        def register_node(f: Items):
246
            node_type = type_ or f.__name__
247
            self._validate_type(node_type)
248
249
            model_type = f.__name__ if callable(node_type) else node_type
250
251
            def node_generator(**kwargs: Any) -> Iterable[Node]:
252
                yield from elements(f(**kwargs), node_type, key=key, value=value)
253
254
            parameters = inspect.getfullargspec(f).args
255
            node_model = NodeModel(type=model_type,
256
                                   parent_type=parent_type,
257
                                   parameters=set(parameters),
258
                                   label=label,
259
                                   uniqueness=unique,
260
                                   multiplicity=multiplicity,
261
                                   generator=node_generator)
262
            self._node_models[node_model.absolute_id].append(node_model)
263
            self._node_children[parent_type].append(model_type)
264
265
            self._validate_node_parameters(parameters)
266
267
        return register_node
268
269
    def edge(self,
270
             type_: Extractor | None = None,
271
             source: Extractor = 'source',
272
             target: Extractor = 'target',
273
             label: Extractor | None = str,
274
             value: Extractor | None = None,
275
             weight: Union[float, Callable[[Any], float]] = 1.0,
276
             ) -> Callable[[Items], None]:
277
        """Decorator to Register a generator of edge payloads as a source of Graph Edges.
278
         It creates an Edge generator function.
279
280
        Args:
281
            type_: Optional source for the Edge Type. Defaults to use Generator function
282
                   name as the Edge Type.
283
            source: Source for edge source Node ID.
284
            target: Source for edge target Node ID.
285
            label: Source for edge label.
286
            value: Source for edge value.
287
            weight: Source for edge weight.
288
289
        Returns:
290
            None.
291
        """
292
293
        def register_edge(f: Items):
294
            edge_type = type_ or f.__name__
295
            self._validate_type(edge_type)
296
297
            model_type = f.__name__ if callable(edge_type) else edge_type
298
299
            getters = {
300
                'source': source,
301
                'target': target,
302
                'label': label,
303
                'type': edge_type,
304
                'value': value,
305
                'weight': weight
306
            }
307
308
            def edge_generator(**kwargs: Any) -> Iterable[Edge]:
309
                yield from elements(f(**kwargs), edge_type, **getters)
310
311
            self._edge_generators[model_type].append(edge_generator)
312
313
        return register_edge
314
315
    def rectify(self, _type: Extractor | None = None,
316
                parent_type: str | None = UniverseNode,
317
                key: Extractor | None = None,
318
                value: Extractor | None = None,
319
                label: Extractor | None = None):
320
        """
321
        Rectify the model.
322
        Add a default NodeModel in case of having just edge supplier/s and no node supplier/s.
323
324
       Args:
325
           _type
326
           parent_type
327
           key
328
           value
329
           label
330
331
       Returns:
332
           None
333
        """
334
        if self._edge_generators and not self._node_models:
335
            @self.node(
336
                type_=_type or 'node',
337
                parent_type=parent_type or 'node',
338
                unique=True,
339
                key=key,
340
                value=value,
341
                label=label or str
342
            )
343
            def node():  # pragma: no cover
344
                return
345
                yield
346
347
348
def model(name: str):
349
    """
350
    Create a graph model
351
352
    Args:
353
        name: model name
354
355
    Returns:
356
        GraphModel
357
    """
358
    return GraphModel(name=name)
359
360
361
__all__ = ('GraphModel', 'Multiplicity', 'model')
362