cvcdocdb 1.0.0a1__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.
cvcdocdb/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ __version__ = "1.0.0a1"
2
+
3
+ from .base import *
4
+ from .drm_entities import *
5
+ from .graph_store import GraphStore
6
+
7
+
8
+ def __getattr__(name):
9
+ """Lazy import for optional backend modules.
10
+
11
+ Neo4jGraph and NetworkXGraph require optional dependencies (neo4j and
12
+ networkx respectively) that may not be installed in all environments.
13
+ """
14
+ if name == "Neo4jGraph":
15
+ from .neo4j_graph import Neo4jGraph
16
+
17
+ return Neo4jGraph
18
+ if name == "NetworkXGraph":
19
+ from .networkx_graph import NetworkXGraph
20
+
21
+ return NetworkXGraph
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
cvcdocdb/base.py ADDED
@@ -0,0 +1,563 @@
1
+ """Core data structures: Node, Relation, WeakNode, WeakRelation.
2
+
3
+ These classes represent the graph primitives used by both Neo4jGraph
4
+ and NetworkXGraph. Nodes carry a primary key (``pk``), a main label,
5
+ optional alternative labels, and optional parent relationships for
6
+ WeakNode hierarchies. Relations connect two nodes with a typed edge.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional, Tuple, Union
10
+
11
+ # Sentinel per distingir "pk no proporcionat" de "pk=None explícit"
12
+ _UNSET = object()
13
+
14
+
15
+ def _single_pk(pk: Dict[str, Union[int, str]], version: int = 5) -> Dict[str, Union[int, str]]:
16
+ """Merge multi-field PK into a single-field PK for Neo4j 3.x compatibility.
17
+
18
+ Args:
19
+ pk: Primary key dictionary.
20
+ version: Neo4j protocol version (3 or 5).
21
+
22
+ Returns:
23
+ A single-field PK dict for version 3, or the original dict for version 5.
24
+ """
25
+ if version == 3 and len(pk) > 1:
26
+ return {"_".join(pk.keys()): "_".join([str(x) for x in pk.values()])}
27
+ return pk
28
+
29
+
30
+ def _new_key(old_key: str, list_keys: set) -> str:
31
+ """Generate a unique key by appending _0, _1, ... until a free name is found.
32
+
33
+ Args:
34
+ old_key: The original key to modify.
35
+ list_keys: Set of existing keys to avoid.
36
+
37
+ Returns:
38
+ A unique key name.
39
+ """
40
+ n = 0
41
+ while old_key + "_" + str(n) in list_keys:
42
+ n += 1
43
+ return old_key + "_" + str(n)
44
+
45
+
46
+ def _mergePK(
47
+ pk_a: Dict[str, Union[int, str]],
48
+ pk_b: Optional[Dict[str, Union[int, str]]],
49
+ version: int = 5,
50
+ ) -> Dict[str, Union[int, str]]:
51
+ """Merge two PK dicts into a composite PK.
52
+
53
+ If pk_b is None, return pk_a as-is (the child inherits the parent's PK).
54
+ """
55
+ if pk_b is None:
56
+ return pk_a
57
+ k = set(pk_a.keys()).intersection(set(pk_b.keys()))
58
+
59
+ for v in k:
60
+ new_key = _new_key(v, set(pk_a.keys()).union(pk_b.keys()))
61
+ pk_b[new_key] = pk_b.pop(v)
62
+
63
+ pk = {**pk_a, **pk_b}
64
+
65
+ return _single_pk(pk, version)
66
+
67
+
68
+ def _setNodePK(value: Dict[str, Any]) -> Dict[str, Any]:
69
+ """Extract main_label and pk from a dict without mutating the original.
70
+
71
+ Args:
72
+ value: dict containing 'main_label' and 'pk' keys.
73
+
74
+ Returns:
75
+ A new dict with 'main_label' and 'pk'. Returns empty values
76
+ if the keys are missing.
77
+ """
78
+ main_label = value.get("main_label")
79
+ pk = value.get("pk")
80
+ return {"main_label": main_label, "pk": pk}
81
+
82
+
83
+ class Node:
84
+ """A graph node with a primary key, labels, and optional parent.
85
+
86
+ Nodes are the fundamental building blocks of the DRM graph. Each node
87
+ has a ``main_label`` (the primary Cypher label), optional ``alternative_labels``,
88
+ and a ``pk`` (primary key) that uniquely identifies it within its label.
89
+
90
+ When a ``parent`` is provided the node becomes a **WeakNode**: its primary
91
+ key is merged with the parent's key to form a composite key, and a typed
92
+ edge is created when the node is inserted into a graph store.
93
+
94
+ By default a node **must** have a ``pk`` or a ``neo4j_id`` (or both).
95
+ If the caller passes ``pk=None`` explicitly, the node is created with
96
+ ``_primary_key = None`` and the backend is expected to assign a real
97
+ ID later (e.g. Neo4j generates an internal node ID, which is then
98
+ stored as ``_primary_key``). If the backend never assigns one,
99
+ ``_primary_key`` remains ``None``.
100
+
101
+ Args:
102
+ pk: Primary key — an int is converted to ``{"id": pk}``, a dict is used
103
+ as-is. Must be provided (or explicitly ``None``) unless
104
+ ``neo4j_id`` is given.
105
+ main_label: The primary label used in Cypher queries.
106
+ alternative_labels: Additional labels attached to the node.
107
+ version: Neo4j protocol version (default 5).
108
+ neo4j_id: Internal Neo4j node ID (used when reconstructing a node
109
+ from a database result).
110
+ **kwargs: Arbitrary additional attributes stored on the node.
111
+ Special kwargs: ``parent`` (Node), ``parent_relation`` (str),
112
+ ``is_weak`` (bool), ``_propagate`` (bool), ``dependencies``.
113
+
114
+ Raises:
115
+ ValueError: If ``pk`` is not provided at all (not even as ``None``)
116
+ and ``neo4j_id`` is also absent.
117
+ TypeError: If ``pk`` is neither an ``int`` nor a ``dict`` (when
118
+ ``pk`` is provided but not ``None``).
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ pk: Union[Dict[str, Union[int, str]], None, object] = _UNSET,
124
+ main_label: str = "",
125
+ alternative_labels: Optional[Union[str, List[str]]] = None,
126
+ version: int = 5,
127
+ neo4j_id: Optional[int] = None,
128
+ **kwargs: Any,
129
+ ):
130
+ # Definim les propietats
131
+ self._neo4j_id = neo4j_id
132
+ self._version = version
133
+
134
+ # Distingim entre "pk no passat" i "pk=None explícit"
135
+ if pk is _UNSET:
136
+ if neo4j_id is not None:
137
+ self._primary_key = {"id": neo4j_id}
138
+ else:
139
+ raise ValueError(
140
+ "Node must have either a primary key (pk) or a neo4j_id. "
141
+ "A node without either cannot be inserted or referenced."
142
+ )
143
+ elif pk is None:
144
+ # pk=None explícit: si hi ha neo4j_id, el fem servir com a PK;
145
+ # si no, el backend generarà un ID després (pk queda None).
146
+ if neo4j_id is not None:
147
+ self._primary_key = {"id": neo4j_id}
148
+ else:
149
+ self._primary_key = None
150
+ elif isinstance(pk, int):
151
+ self._primary_key = {"id": pk}
152
+ elif isinstance(pk, Dict):
153
+ self._primary_key = _single_pk(pk, version)
154
+ else:
155
+ raise TypeError(
156
+ f"pk must be an int, dict, or None, got {type(pk).__name__}"
157
+ )
158
+
159
+ self._main_label = main_label # main_label.lower().capitalize()
160
+
161
+ if isinstance(alternative_labels, str):
162
+ self._label = [
163
+ alternative_labels
164
+ ] # [alternative_labels.lower().capitalize()]
165
+ else:
166
+ self._label = (
167
+ alternative_labels
168
+ if alternative_labels is None
169
+ else [x for x in alternative_labels] # x.lower().capitalize()
170
+ )
171
+
172
+ self._is_weak = kwargs.pop("is_weak", False)
173
+ self._propagate = kwargs.pop("_propagate", False)
174
+ parent_relation = kwargs.pop("parent_relation", None)
175
+ self._parent = kwargs.pop("parent", None)
176
+ if self._parent is not None:
177
+ assert isinstance(self._parent, Node), "parent must be a Node"
178
+ self._is_weak = True
179
+ if self._parent._primary_key is None:
180
+ raise ValueError(
181
+ "WeakNode parent must have a primary key. "
182
+ "Transient nodes (no pk) cannot be parents."
183
+ )
184
+ self._primary_key = _mergePK(self._parent._primary_key, self._primary_key)
185
+ self._parent_relation = (
186
+ parent_relation if parent_relation is not None else "HAS"
187
+ )
188
+
189
+ dependencies = kwargs.pop("dependencies", False)
190
+ if dependencies:
191
+ self._dependencies = dependencies
192
+ else:
193
+ self._dependencies = None
194
+
195
+ if kwargs is not None:
196
+ for k in kwargs:
197
+ self.__setattr__(k, kwargs[k])
198
+
199
+ # ------------------------------------------------------------------
200
+ # Dunder methods
201
+ # ------------------------------------------------------------------
202
+
203
+ def __repr__(self):
204
+ main_label = ":" + self._main_label if len(self._main_label) > 0 else ""
205
+ id = " <Id>: " + str(self._neo4j_id) if self._neo4j_id is not None else ""
206
+ mess = "(a" + main_label + id + ")"
207
+ pk_repr = self._primary_key.__repr__() if self._primary_key is not None else "None"
208
+ mess += """, pk:""" + pk_repr
209
+ mess += (
210
+ (", attributes:" + self.attributes[1].__repr__())
211
+ if len(self.attributes[1]) > 0
212
+ else ""
213
+ )
214
+ return mess
215
+
216
+ def __getitem__(self, key: str) -> Any:
217
+ def get_pk():
218
+ if hasattr(self,'_primary_key'):
219
+ return self._primary_key
220
+ if hasattr(self,'_neo4j_id'):
221
+ return { 'neo4j_id': self._neo4j_id}
222
+
223
+ return 0
224
+
225
+ if key == "pk":
226
+ return {"main_label": self._main_label, "pk": get_pk() }
227
+
228
+ if key == "main_label":
229
+ return self._main_label
230
+
231
+ if key == "labels":
232
+ if self._label is None:
233
+ return [self._main_label]
234
+ else:
235
+ return [self._main_label] + self._label
236
+
237
+ if key == "pk_attributes":
238
+ return get_pk()
239
+
240
+ attr = self.__dict__.copy()
241
+ if key == "attributes":
242
+ attr.pop("_label", None)
243
+ attr.pop("_main_label")
244
+ attr.pop("_neo4j_id", None)
245
+ pk = attr.pop("_primary_key", None)
246
+
247
+ return pk, attr
248
+
249
+ if "_" + key in attr:
250
+ return attr["_" + key]
251
+ else:
252
+ raise Exception(key + " is not a node attribute")
253
+
254
+ def __setitem__(self, key, value):
255
+ if key == "pk":
256
+ aux = _setNodePK(value)
257
+ self._main_label, self._pk = aux["_main_label"], aux["pk"]
258
+ return
259
+
260
+ if key == "main_label":
261
+ self._main_label = value
262
+ return
263
+
264
+ if key == "labels":
265
+ self._label = value
266
+ return
267
+
268
+ if key == "pk_attributes":
269
+ if isinstance(value, dict):
270
+ self._primary_key = value
271
+ else:
272
+ raise Exception("dictionary expected")
273
+
274
+ return
275
+
276
+ self.__setattr__(key, value)
277
+
278
+ def __delitem__(self, key: str) -> None:
279
+ del self.__dict__[key]
280
+
281
+ def __contains__(self, key: str) -> bool:
282
+ return key in self.__dict__ or "_" + key in self.__dict__
283
+
284
+ def get(self, key: str, default: Any = None) -> Any:
285
+ """Dict-like access: return the value for *key*, or *default* if missing.
286
+
287
+ This method provides a compatibility shim so that code written
288
+ with ``node.get("key", default)`` works on Node objects even
289
+ though they are not full ``dict`` subclasses.
290
+
291
+ Args:
292
+ key: The attribute key to retrieve.
293
+ default: Value returned when the key is not found.
294
+
295
+ Returns:
296
+ The attribute value, or *default* if the key is absent.
297
+ """
298
+ try:
299
+ return self[key]
300
+ except Exception:
301
+ return default
302
+
303
+ # ------------------------------------------------------------------
304
+ # Properties
305
+ # ------------------------------------------------------------------
306
+
307
+ @property
308
+ def version(self) -> int:
309
+ return self._version
310
+
311
+ @version.setter
312
+ def version(self, value: int) -> None:
313
+ self._version = value
314
+ if value == 3 and self._primary_key is not None:
315
+ self._primary_key = _single_pk(self._primary_key, value)
316
+
317
+ @property
318
+ def attributes(self) -> Tuple[Optional[Dict[str, Union[int, str]]], Dict[str, Any]]:
319
+ """Return (pk, attributes) tuple for this node.
320
+
321
+ Returns:
322
+ A tuple of (primary_key_dict_or_None, attributes_dict).
323
+ """
324
+ at = self.__dict__.copy()
325
+ pk = at.pop("_primary_key", None)
326
+ for x in list(at.keys()):
327
+ if x[0] == "_":
328
+ at.pop(x, None)
329
+ return pk, at
330
+
331
+ @property
332
+ def labels(self) -> List[str]:
333
+ """Return the full list of labels (main + alternative)."""
334
+ if self._label is None:
335
+ return [self._main_label]
336
+ return [self._main_label] + self._label
337
+
338
+ @property
339
+ def main_label(self) -> str:
340
+ """Return the primary Cypher label."""
341
+ return self._main_label
342
+
343
+ @property
344
+ def neo4j_id(self) -> Optional[int]:
345
+ """Return the internal Neo4j node id, if set."""
346
+ return self._neo4j_id
347
+
348
+ @neo4j_id.setter
349
+ def neo4j_id(self, value: Optional[int]) -> None:
350
+ self._neo4j_id = value
351
+
352
+ # ------------------------------------------------------------------
353
+ # Utility methods
354
+ # ------------------------------------------------------------------
355
+
356
+ def keys(self) -> List[str]:
357
+ """Return all public attribute names."""
358
+ return [k for k in self.__dict__.keys() if not k.startswith("_")]
359
+
360
+
361
+ # Class Relation denotes the relation of two nodes in the graph database
362
+ class Relation:
363
+ """A typed edge connecting two nodes in the graph.
364
+
365
+ Relations store the primary keys of their source and destination nodes
366
+ and can carry arbitrary edge properties.
367
+
368
+ Args:
369
+ src: Source node. Its ``pk`` is extracted and stored.
370
+ dst: Destination node. Its ``pk`` is extracted and stored.
371
+ type: Relation type (e.g. "HAS_NOM", "CONNECTS"). Stored uppercase.
372
+ **kwargs: Edge properties stored as attributes.
373
+ """
374
+
375
+ def __init__(self, src: Node, dst: Node, rel_type: str, **kwargs: Any) -> None:
376
+ """Initialize a Relation between two nodes.
377
+
378
+ Args:
379
+ src: Source node. Its ``pk`` is extracted and stored.
380
+ dst: Destination node. Its ``pk`` is extracted and stored.
381
+ rel_type: Relation type (e.g. "HAS_NOM", "CONNECTS"). Stored uppercase.
382
+ **kwargs: Edge properties stored as attributes.
383
+ """
384
+ self._type = rel_type.upper()
385
+ self._src = src["pk"]
386
+ self._dst = dst["pk"]
387
+
388
+ if kwargs is not None:
389
+ for k in kwargs:
390
+ self.__setattr__(k, kwargs[k])
391
+
392
+ # ------------------------------------------------------------------
393
+ # Dunder methods
394
+ # ------------------------------------------------------------------
395
+
396
+ def __repr__(self) -> str:
397
+ return (
398
+ "src:" + str(self._src) + ", dst:" + str(self._dst)
399
+ + " type:" + self._type
400
+ )
401
+
402
+ def __getitem__(self, key: str) -> Any:
403
+ if key == "src":
404
+ return {"main_label": self._src["main_label"], "pk": self._src["pk"]}
405
+
406
+ if key == "dst":
407
+ return {"main_label": self._dst["main_label"], "pk": self._dst["pk"]}
408
+
409
+ if key == "type":
410
+ return self._type
411
+
412
+ attr = self.__dict__.copy()
413
+ if key == "attributes":
414
+ # remove private attributes
415
+ attr.pop("_src", None)
416
+ attr.pop("_dst", None)
417
+ attr.pop("_type", None)
418
+
419
+ if attr == {}:
420
+ return None
421
+ else:
422
+ return attr
423
+
424
+ if key in attr:
425
+ return attr[key]
426
+ raise KeyError(key + " is not a relation attribute")
427
+
428
+ def __setitem__(self, key: str, value: Any) -> None:
429
+ if key == "src":
430
+ self._src = _setNodePK(value)
431
+ return
432
+
433
+ if key == "dst":
434
+ self._dst = _setNodePK(value)
435
+ return
436
+
437
+ if key == "type":
438
+ self._type = value
439
+ return
440
+
441
+ self.__setattr__(key, value)
442
+
443
+ # ------------------------------------------------------------------
444
+ # Properties
445
+ # ------------------------------------------------------------------
446
+
447
+ @property
448
+ def src(self) -> Tuple[str, Optional[Dict[str, Union[int, str]]]]:
449
+ """Return (main_label, pk) of the source node."""
450
+ return self._src["main_label"], self._src["pk"]
451
+
452
+ @src.setter
453
+ def src(self, value: Dict[str, Any]) -> None:
454
+ self._src = _setNodePK(value)
455
+
456
+ @property
457
+ def dst(self) -> Tuple[str, Optional[Dict[str, Union[int, str]]]]:
458
+ """Return (main_label, pk) of the destination node."""
459
+ return self._dst["main_label"], self._dst["pk"]
460
+
461
+ @dst.setter
462
+ def dst(self, value: Dict[str, Any]) -> None:
463
+ self._dst = _setNodePK(value)
464
+
465
+ @property
466
+ def type(self) -> str:
467
+ """Return the relation type (uppercase)."""
468
+ return self._type
469
+
470
+ @property
471
+ def attributes(self) -> Optional[Dict[str, Any]]:
472
+ """Return edge attributes, or None if empty."""
473
+ attr = {
474
+ k: v for k, v in self.__dict__.items()
475
+ if not k.startswith("_")
476
+ }
477
+ return attr if attr else None
478
+
479
+
480
+ class WeakNode(Node):
481
+ """A node whose identity is tied to its parent node.
482
+
483
+ WeakNodes form a parent-child hierarchy where the child's primary
484
+ key is **merged** with the parent's key to produce a composite key.
485
+ This models document structures such as *Document → Section → Page*
486
+ where a child cannot exist without its parent.
487
+
488
+ When a WeakNode is inserted into a graph store, a typed edge
489
+ (``WeakRelation``) is automatically created linking the parent to
490
+ the child. This edge carries the ``_propagate=TRUE`` flag, which
491
+ triggers cascade delete: deleting the parent automatically deletes
492
+ all descendants in the hierarchy.
493
+
494
+ Args:
495
+ parent: The parent ``Node``. Must not be None.
496
+ **kwargs: Forwarded to :class:`Node.__init__`. Common kwargs
497
+ include ``pk`` (child's primary key), ``main_label``,
498
+ ``alternative_labels``, ``parent_relation`` (default
499
+ ``"HAS"``), and ``_propagate``.
500
+
501
+ Raises:
502
+ AssertionError: If ``parent`` is not a ``Node`` instance.
503
+ """
504
+
505
+ def __init__(self, **kwargs: Any) -> None:
506
+ """Initialize a WeakNode tied to a parent node.
507
+
508
+ Args:
509
+ parent: The parent ``Node``. Required — must be passed as a
510
+ kwarg.
511
+ **kwargs: Passed to :class:`Node.__init__` (``pk``,
512
+ ``main_label``, ``alternative_labels``,
513
+ ``parent_relation``, ``_propagate``, etc.).
514
+
515
+ Raises:
516
+ AssertionError: If ``parent`` is not provided or is not a
517
+ ``Node`` instance.
518
+ """
519
+ parent = kwargs.pop("parent", None)
520
+ if parent is None:
521
+ raise ValueError(
522
+ "WeakNode requires a 'parent' argument. "
523
+ "Pass parent=<Node> to establish the parent-child relationship."
524
+ )
525
+ assert isinstance(parent, Node), "parent must be a Node"
526
+ kwargs.pop("is_weak", None)
527
+ super().__init__(is_weak=True, parent=parent, **kwargs)
528
+
529
+
530
+ class WeakRelation(Relation):
531
+ """A typed edge connecting a parent node to its child (WeakNode).
532
+
533
+ WeakRelations are automatically created when a WeakNode is inserted.
534
+ They carry the ``_propagate=TRUE`` flag, which signals to the graph
535
+ store that deleting the parent should cascade to the child node.
536
+
537
+ Args:
538
+ src: Source (parent) node.
539
+ dst: Destination (child / WeakNode).
540
+ rel_type: Relation type (e.g. ``"HAS_PAGE"``, ``"CONTAINS"``).
541
+ **kwargs: Edge properties. The ``propagate`` kwarg controls
542
+ whether the ``_propagate`` flag is set (default ``True``).
543
+
544
+ Attributes:
545
+ _propagate: Always ``True`` by default. Indicates that deleting
546
+ the source node should cascade delete to the destination node.
547
+ """
548
+
549
+ def __init__(
550
+ self, src: Node, dst: Node, rel_type: str, **kwargs: Any
551
+ ) -> None:
552
+ """Initialize a WeakRelation with cascade propagation.
553
+
554
+ Args:
555
+ src: Source (parent) node.
556
+ dst: Destination (child) node.
557
+ rel_type: Relation type.
558
+ propagate: If True (default), the edge carries the
559
+ ``_propagate=TRUE`` flag for cascade delete.
560
+ """
561
+ super().__init__(
562
+ src, dst, rel_type, _propagate=kwargs.pop("propagate", True), **kwargs
563
+ )