sonnet-graph 0.3.2__tar.gz → 0.4.0__tar.gz

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 (27) hide show
  1. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/PKG-INFO +2 -2
  2. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/pyproject.toml +5 -2
  3. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/__init__.py +1 -7
  4. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/context.py +7 -1
  5. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/metadata/__init__.py +12 -34
  6. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/metadata/model_store.py +48 -8
  7. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/metadata/models.py +1 -25
  8. sonnet_graph-0.4.0/src/sonnet_graph/metadata/seeding.py +64 -0
  9. sonnet_graph-0.4.0/src/sonnet_graph/py.typed +0 -0
  10. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph.egg-info/PKG-INFO +2 -2
  11. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph.egg-info/SOURCES.txt +1 -1
  12. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph.egg-info/requires.txt +1 -1
  13. sonnet_graph-0.3.2/src/sonnet_graph/metadata/query_store.py +0 -264
  14. sonnet_graph-0.3.2/src/sonnet_graph/metadata/seeding.py +0 -170
  15. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/README.md +0 -0
  16. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/setup.cfg +0 -0
  17. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/checks/__init__.py +0 -0
  18. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/checks/graphdb_health_check.py +0 -0
  19. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/checks/graphdb_initialization.py +0 -0
  20. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/checks/stages.py +0 -0
  21. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/dependencies.py +0 -0
  22. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/extension.py +0 -0
  23. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/metadata/graph_info.py +0 -0
  24. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/metadata/template_resolver.py +0 -0
  25. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph/status.py +0 -0
  26. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph.egg-info/dependency_links.txt +0 -0
  27. {sonnet_graph-0.3.2 → sonnet_graph-0.4.0}/src/sonnet_graph.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonnet-graph
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Graph database integration (cypher-graphdb) for sonnet-server applications
5
5
  Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -12,7 +12,7 @@ Requires-Python: <4.0,>=3.14
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: sonnet-core>=0.1.0
14
14
  Requires-Dist: sonnet-server>=0.4.0
15
- Requires-Dist: cypher-graphdb>=0.5.0
15
+ Requires-Dist: cypher-graphdb>=0.7.1
16
16
  Requires-Dist: loguru>=0.7.3
17
17
  Requires-Dist: jinja2>=3.1.6
18
18
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sonnet-graph"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  authors = [
9
9
  { name = "Wolfgang Miller", email = "wolfgang.miller@petrarca-labs.com" },
10
10
  ]
@@ -21,7 +21,7 @@ classifiers = [
21
21
  dependencies = [
22
22
  "sonnet-core>=0.1.0",
23
23
  "sonnet-server>=0.4.0",
24
- "cypher-graphdb>=0.5.0",
24
+ "cypher-graphdb>=0.7.1",
25
25
  "loguru>=0.7.3",
26
26
  "jinja2>=3.1.6",
27
27
  ]
@@ -37,6 +37,9 @@ dev = [
37
37
  [tool.setuptools.packages.find]
38
38
  where = ["src"]
39
39
 
40
+ [tool.setuptools.package-data]
41
+ sonnet_graph = ["py.typed"]
42
+
40
43
  [tool.pytest.ini_options]
41
44
  markers = [
42
45
  "unit: marks tests as unit tests (default)",
@@ -33,11 +33,9 @@ Public API:
33
33
 
34
34
  Graph metadata:
35
35
  GraphModelStore -- load/save/remove :_GraphModel node (schema + description)
36
- GraphQueryStore -- load/save/remove :_NamedQuery nodes (queries)
37
36
  init_graph_metadata -- initialize model + description (one-time setup)
38
- seed_named_queries -- seed :_NamedQuery nodes from file pairs
39
37
  get_graph_info -- LLM-friendly schema + description in one call
40
- resolve_template -- Jinja2 template resolution for named queries
38
+ resolve_template -- stateless Jinja2 template resolution
41
39
 
42
40
  FastAPI dependencies:
43
41
  get_graphdb_session -- factory for router-level ambient session dependency
@@ -74,11 +72,9 @@ from sonnet_graph.dependencies import get_graphdb, get_graphdb_session
74
72
  from sonnet_graph.extension import GraphDBExtension
75
73
  from sonnet_graph.metadata import (
76
74
  GraphModelStore,
77
- GraphQueryStore,
78
75
  get_graph_info,
79
76
  init_graph_metadata,
80
77
  resolve_template,
81
- seed_named_queries,
82
78
  )
83
79
  from sonnet_graph.status import GraphPoolStatus, PoolInfo, snapshot_pools
84
80
 
@@ -118,9 +114,7 @@ __all__ = [
118
114
  "GraphdbStage",
119
115
  # Graph metadata
120
116
  "GraphModelStore",
121
- "GraphQueryStore",
122
117
  "get_graph_info",
123
118
  "init_graph_metadata",
124
119
  "resolve_template",
125
- "seed_named_queries",
126
120
  ]
@@ -201,7 +201,13 @@ def register_graph(name: str, *, env_prefix: str | None = None, settings: Cypher
201
201
  raise ValueError("register_graph requires one of 'env_prefix' or 'settings' -- neither was given.")
202
202
  if env_prefix is not None and settings is not None:
203
203
  raise ValueError("register_graph requires exactly one of 'env_prefix' or 'settings' -- both were given.")
204
- _graph_specs[name] = settings if settings is not None else env_prefix
204
+ # Exactly one is non-None here; assign in explicit branches so the narrowed
205
+ # (non-None) type is what lands in _graph_specs[str, CypherSettings | str].
206
+ if settings is not None:
207
+ _graph_specs[name] = settings
208
+ else:
209
+ assert env_prefix is not None # guaranteed by the checks above; narrows for the type checker
210
+ _graph_specs[name] = env_prefix
205
211
  logger.debug("register_graph: '{}' registered ({})", name, "settings" if settings is not None else f"env_prefix={env_prefix}")
206
212
 
207
213
 
@@ -1,13 +1,12 @@
1
- """Graph metadata -- self-describing graph nodes for model, description, and queries.
1
+ """Graph metadata -- self-describing graph nodes for model and description.
2
2
 
3
- The graph stores its own metadata as dedicated nodes prefixed with
3
+ The graph stores its own metadata as a dedicated node prefixed with
4
4
  underscore to indicate internal infrastructure:
5
5
 
6
6
  - :_GraphModel -- graph schema (node/edge types, properties) and
7
7
  human-readable description (markdown)
8
- - :_NamedQuery -- pre-defined queries with metadata (cypher, jinja2, datalog)
9
8
 
10
- Each metadata type has a facade store and a typed model.
9
+ The metadata type has a facade store and a typed model.
11
10
 
12
11
  Two paths to seed a graph model:
13
12
 
@@ -40,12 +39,11 @@ Graph description (stored on the :_GraphModel node)::
40
39
  content, fmt = store.load_description()
41
40
  # -> ("# Tech Stack Graph\\n...", "text/markdown")
42
41
 
43
- Named queries::
42
+ Initialize model + description in one call::
44
43
 
45
- from sonnet_graph import borrow_graphdb, GraphQueryStore, init_graph_metadata, resolve_template
44
+ from sonnet_graph import borrow_graphdb, init_graph_metadata
46
45
 
47
46
  with borrow_graphdb("techstack") as cdb:
48
- # Initialize model + description
49
47
  init_graph_metadata(
50
48
  cdb,
51
49
  models_path="plugins/tech_stack/graph_models.py",
@@ -53,58 +51,38 @@ Named queries::
53
51
  )
54
52
  cdb.commit()
55
53
 
56
- # Or store individual queries
57
- store = GraphQueryStore(cdb)
58
- store.save(
59
- query_key="hub-technologies",
60
- title="Hub Technologies",
61
- query_content="MATCH (t:Technology)<-[:USES]-(c) RETURN t, count(c) ORDER BY count(c) DESC",
62
- language="cypher",
63
- tags=["analysis"],
64
- )
65
- cdb.commit()
66
-
67
- queries = store.list() # metadata only
68
- query = store.load("hub-technologies") # full detail
69
- cypher = resolve_template(query["query_content"], language=query["language"])
54
+ Named queries are a product-owned feature: their storage and lifecycle belong
55
+ to the consuming application, not to graph metadata. sonnet-graph provides only
56
+ ``resolve_template`` (stateless Jinja2 rendering) for callers that resolve
57
+ parameterized query text themselves.
70
58
 
71
59
  Public API:
72
60
 
73
61
  Stores:
74
62
  GraphModelStore -- load/save/remove :_GraphModel node (schema + description)
75
- GraphQueryStore -- load/save/remove :_NamedQuery nodes
76
63
 
77
64
  Initialization:
78
65
  init_graph_metadata -- initialize model + description (lauro graph init)
79
66
 
80
- Query seeding:
81
- seed_named_queries -- seed :_NamedQuery nodes from file pairs
82
-
83
67
  Helpers:
84
68
  get_graph_info -- LLM-friendly schema + description in one call
85
- resolve_template -- Jinja2 template resolution for named queries
69
+ resolve_template -- stateless Jinja2 template resolution
86
70
 
87
71
  Node models:
88
72
  GraphModelNode -- model for the _GraphModel label
89
- NamedQueryNode -- model for the _NamedQuery label
90
73
  """
91
74
 
92
75
  from sonnet_graph.metadata.graph_info import get_graph_info
93
76
  from sonnet_graph.metadata.model_store import GraphModelStore
94
- from sonnet_graph.metadata.models import GraphModelNode, NamedQueryNode
95
- from sonnet_graph.metadata.query_store import GraphQueryStore
96
- from sonnet_graph.metadata.seeding import QuerySeedResult, init_graph_metadata, seed_named_queries
77
+ from sonnet_graph.metadata.models import GraphModelNode
78
+ from sonnet_graph.metadata.seeding import init_graph_metadata
97
79
  from sonnet_graph.metadata.template_resolver import TemplateResolutionError, resolve_template
98
80
 
99
81
  __all__ = [
100
82
  "GraphModelNode",
101
83
  "GraphModelStore",
102
- "GraphQueryStore",
103
- "NamedQueryNode",
104
- "QuerySeedResult",
105
84
  "TemplateResolutionError",
106
85
  "get_graph_info",
107
86
  "init_graph_metadata",
108
87
  "resolve_template",
109
- "seed_named_queries",
110
88
  ]
@@ -101,6 +101,8 @@ class GraphModelStore:
101
101
 
102
102
  - ``save_from_provider(provider)`` -- models already registered
103
103
  in a ModelProvider (e.g. a graph_models module's ``mp``)
104
+ - ``sync_from_provider(provider)`` -- the same, written only when the
105
+ stored schema, name or version differ
104
106
  - ``save_from_path(path)`` -- models in a Python file/directory
105
107
  - ``save_from_file(path)`` -- pre-generated graph.schema.json
106
108
  - ``save(schema_dict)`` -- raw schema dict
@@ -276,6 +278,20 @@ class GraphModelStore:
276
278
 
277
279
  self._cdb.create_or_merge(node)
278
280
 
281
+ @staticmethod
282
+ def schema_from_provider(provider: ModelProvider) -> dict[str, Any]:
283
+ """Generate the combined JSON Schema of a ModelProvider's registered models.
284
+
285
+ Raises:
286
+ ValueError: If the provider has no models with schemas.
287
+ """
288
+ from cypher_graphdb.utils.schema_utils import combine_schemas, extract_schemas_from_model_infos
289
+
290
+ model_infos = [info for info in provider.values() if info.graph_schema]
291
+ if not model_infos:
292
+ raise ValueError("No models with schemas found in the provider")
293
+ return combine_schemas(extract_schemas_from_model_infos(model_infos))
294
+
279
295
  def save_from_provider(
280
296
  self, provider: ModelProvider, *, name: str | None = None, version: str | None = None
281
297
  ) -> dict[str, Any]:
@@ -292,17 +308,41 @@ class GraphModelStore:
292
308
  Raises:
293
309
  ValueError: If the provider has no models with schemas.
294
310
  """
295
- from cypher_graphdb.utils.schema_utils import combine_schemas, extract_schemas_from_model_infos
296
-
297
- model_infos = [info for info in provider.values() if info.graph_schema]
298
- if not model_infos:
299
- raise ValueError("No models with schemas found in the provider")
300
-
301
- schemas = extract_schemas_from_model_infos(model_infos)
302
- combined = combine_schemas(schemas)
311
+ combined = self.schema_from_provider(provider)
303
312
  self.save(combined, name=name, version=version)
304
313
  return combined
305
314
 
315
+ def sync_from_provider(self, provider: ModelProvider, *, name: str | None = None, version: str | None = None) -> bool:
316
+ """Save the provider's schema unless the stored one is already the same; return whether it was written.
317
+
318
+ Compares in the stored form (the schema as JSON), with the name and
319
+ version ``save`` would write: a changed model, name or version
320
+ rewrites the node, an unchanged one leaves it -- and its
321
+ ``updated_at`` -- as it is. Either way the schema's types are
322
+ registered into the connection's ModelProvider, as after a save.
323
+
324
+ Raises:
325
+ ValueError: If the provider has no models with schemas.
326
+ """
327
+ schema = self.schema_from_provider(provider)
328
+ if self._is_stored(schema, name=name, version=version):
329
+ self._register_schema(schema)
330
+ return False
331
+ self.save(schema, name=name, version=version)
332
+ return True
333
+
334
+ def _is_stored(self, schema: dict[str, Any], *, name: str | None, version: str | None) -> bool:
335
+ """Whether the stored node holds exactly what ``save(schema, name=, version=)`` would write."""
336
+ node = self._load_node()
337
+ if node is None or not node.data:
338
+ return False
339
+ try:
340
+ stored = json.loads(node.data)
341
+ except json.JSONDecodeError, TypeError:
342
+ return False
343
+ as_stored = json.loads(json.dumps(schema, ensure_ascii=False))
344
+ return stored == as_stored and node.name == (name or schema.get("title", "")) and (node.version or "") == (version or "")
345
+
306
346
  def save_from_path(
307
347
  self,
308
348
  models_path: str | Path,
@@ -18,7 +18,7 @@ metadata_provider = ModelProvider()
18
18
 
19
19
 
20
20
  def ensure_metadata_types(provider: ModelProvider | None) -> None:
21
- """Seed the metadata node types (_GraphModel, _NamedQuery) into ``provider``.
21
+ """Seed the metadata node types (_GraphModel) into ``provider``.
22
22
 
23
23
  Idempotent and non-destructive: a graph that legitimately declares its own
24
24
  type under one of these labels keeps it (``overwrite=False``).
@@ -57,27 +57,3 @@ class GraphModelNode(GraphNode):
57
57
  description: str | None = None # human-readable documentation (markdown)
58
58
  description_format: str | None = None # MIME type: text/markdown, text/plain, text/html
59
59
  updated_at: str | None = None
60
-
61
-
62
- @metadata_provider.node(label="_NamedQuery", merge_keys=["query_key"])
63
- class NamedQueryNode(GraphNode):
64
- """A pre-defined query with metadata.
65
-
66
- Multiple per graph, keyed by ``query_key`` (``merge_keys=["query_key"]``).
67
- The ``query_content`` holds the query text -- plain Cypher, a Jinja2
68
- template, or Datalog rules. The ``language`` field discriminates.
69
-
70
- The ``tags`` and ``params_schema`` and ``ui_schema`` fields are
71
- JSON-serialized strings for cross-backend compatibility (AGE
72
- stores arrays as JSON strings).
73
- """
74
-
75
- query_key: str | None = None
76
- title: str | None = None
77
- description: str | None = None
78
- tags: str | None = None # JSON-serialized list[str]
79
- query_content: str | None = None
80
- language: str | None = None # cypher | jinja2 | datalog
81
- params_schema: str | None = None # JSON-serialized JSON Schema
82
- ui_schema: str | None = None # JSON-serialized UI hints
83
- updated_at: str | None = None
@@ -0,0 +1,64 @@
1
+ """Graph metadata initialization.
2
+
3
+ ``init_graph_metadata(cdb, ...)`` -- initialize model and description
4
+ on the :_GraphModel node. One-time setup, called by
5
+ ``lauro graph init``. Raises on failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from cypher_graphdb import CypherGraphDB
13
+ from loguru import logger
14
+
15
+ from sonnet_graph.metadata.model_store import GraphModelStore
16
+
17
+
18
+ def init_graph_metadata(
19
+ cdb: CypherGraphDB,
20
+ *,
21
+ model: dict | None = None,
22
+ models_path: str | Path | None = None,
23
+ model_name: str | None = None,
24
+ model_version: str | None = None,
25
+ description: str | None = None,
26
+ ) -> None:
27
+ """Initialize graph metadata -- model schema and description.
28
+
29
+ Writes the :_GraphModel node with schema and optional description.
30
+ Intended for one-time graph initialization (``lauro graph init``).
31
+ Does NOT call ``commit()`` -- the caller is responsible.
32
+
33
+ Args:
34
+ cdb: Active CypherGraphDB connection.
35
+ model: JSON Schema dict to write as :_GraphModel node.
36
+ models_path: Path to a Python file or directory declaring graph
37
+ models on a ModelProvider. Generates and saves the schema.
38
+ Mutually exclusive with ``model``.
39
+ model_name: Graph model name.
40
+ model_version: Schema version string.
41
+ description: Markdown string stored on the :_GraphModel node.
42
+
43
+ Raises:
44
+ ValueError: If both ``model`` and ``models_path`` are provided.
45
+ ValueError: If ``description`` is provided without a model/models_path
46
+ and no :_GraphModel node already exists in the graph. Use
47
+ ``GraphModelStore.update_description()`` directly to update
48
+ an existing node's description independently.
49
+ """
50
+ if model is not None and models_path is not None:
51
+ raise ValueError("Provide either 'model' or 'models_path', not both")
52
+
53
+ store = GraphModelStore(cdb)
54
+
55
+ if model is not None:
56
+ store.save(model, name=model_name, version=model_version)
57
+ logger.debug("Initialized graph model '{}' from dict", model_name or "")
58
+ elif models_path is not None:
59
+ store.save_from_path(models_path, name=model_name, version=model_version)
60
+ logger.debug("Initialized graph model '{}' from path '{}'", model_name or "", models_path)
61
+
62
+ if description is not None:
63
+ store.update_description(description)
64
+ logger.debug("Updated graph description")
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonnet-graph
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Graph database integration (cypher-graphdb) for sonnet-server applications
5
5
  Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -12,7 +12,7 @@ Requires-Python: <4.0,>=3.14
12
12
  Description-Content-Type: text/markdown
13
13
  Requires-Dist: sonnet-core>=0.1.0
14
14
  Requires-Dist: sonnet-server>=0.4.0
15
- Requires-Dist: cypher-graphdb>=0.5.0
15
+ Requires-Dist: cypher-graphdb>=0.7.1
16
16
  Requires-Dist: loguru>=0.7.3
17
17
  Requires-Dist: jinja2>=3.1.6
18
18
  Provides-Extra: dev
@@ -4,6 +4,7 @@ src/sonnet_graph/__init__.py
4
4
  src/sonnet_graph/context.py
5
5
  src/sonnet_graph/dependencies.py
6
6
  src/sonnet_graph/extension.py
7
+ src/sonnet_graph/py.typed
7
8
  src/sonnet_graph/status.py
8
9
  src/sonnet_graph.egg-info/PKG-INFO
9
10
  src/sonnet_graph.egg-info/SOURCES.txt
@@ -18,6 +19,5 @@ src/sonnet_graph/metadata/__init__.py
18
19
  src/sonnet_graph/metadata/graph_info.py
19
20
  src/sonnet_graph/metadata/model_store.py
20
21
  src/sonnet_graph/metadata/models.py
21
- src/sonnet_graph/metadata/query_store.py
22
22
  src/sonnet_graph/metadata/seeding.py
23
23
  src/sonnet_graph/metadata/template_resolver.py
@@ -1,6 +1,6 @@
1
1
  sonnet-core>=0.1.0
2
2
  sonnet-server>=0.4.0
3
- cypher-graphdb>=0.5.0
3
+ cypher-graphdb>=0.7.1
4
4
  loguru>=0.7.3
5
5
  jinja2>=3.1.6
6
6
 
@@ -1,264 +0,0 @@
1
- """GraphQueryStore -- facade for :_NamedQuery nodes in a graph.
2
-
3
- Stores pre-defined queries with metadata. Multiple per graph, keyed
4
- by ``query_key`` (kebab-case). Supports three query languages:
5
-
6
- - ``cypher`` -- plain Cypher, executed directly
7
- - ``jinja2`` -- Jinja2 Cypher template, resolved via ``resolve_template()``
8
- before execution
9
- - ``datalog`` -- Souffle-compatible Datalog rules, resolved by the
10
- consumer's Datalog engine (e.g. lauro)
11
-
12
- The store is pure CRUD -- it never executes or resolves queries.
13
- Resolution is the consumer's responsibility.
14
-
15
- Usage::
16
-
17
- from sonnet_graph import borrow_graphdb, GraphQueryStore
18
-
19
- with borrow_graphdb("techstack") as cdb:
20
- store = GraphQueryStore(cdb)
21
-
22
- # Save a query
23
- store.save(
24
- query_key="hub-technologies",
25
- title="Hub Technologies",
26
- description="Technologies used by the most components",
27
- tags=["analysis", "technology"],
28
- query_content="MATCH (c:Component)-[:USES_TECHNOLOGY]->(t) ...",
29
- language="cypher",
30
- )
31
- cdb.commit()
32
-
33
- # List all (metadata only, no query_content)
34
- queries = store.list()
35
-
36
- # List filtered by tag
37
- queries = store.list(tags=["analysis"])
38
-
39
- # Load full query
40
- query = store.load("hub-technologies")
41
-
42
- # Remove one
43
- store.remove("hub-technologies")
44
-
45
- # Remove all (clean re-seed)
46
- store.remove_all()
47
- cdb.commit()
48
- """
49
-
50
- from __future__ import annotations
51
-
52
- import builtins
53
- import json
54
- from typing import Any
55
-
56
- import arrow
57
- from cypher_graphdb import CypherGraphDB
58
-
59
- from sonnet_graph.metadata.models import NamedQueryNode, ensure_metadata_types
60
-
61
-
62
- class GraphQueryStore:
63
- """Load/save/remove :_NamedQuery nodes in a graph.
64
-
65
- Each query is identified by ``query_key`` (unique within the graph).
66
- Uses ``create_or_merge`` with ``merge_keys=["query_key"]`` so each
67
- node gets a proper ``gid_`` for cypher-graphdb identity.
68
-
69
- The ``tags`` field is stored as a JSON-serialized string for
70
- cross-backend compatibility (AGE stores arrays as JSON strings).
71
- The ``list()`` and ``load()`` methods deserialize it back to
72
- ``list[str]`` in the returned dicts.
73
- """
74
-
75
- def __init__(self, cdb: CypherGraphDB) -> None:
76
- self._cdb = cdb
77
- # The metadata types must be known to the connection's provider or
78
- # these nodes come back as generic GraphNode. Seed them here so the
79
- # store works on any connection, pooled or built directly.
80
- ensure_metadata_types(cdb.model_provider)
81
-
82
- # -- list ----------------------------------------------------------------
83
-
84
- def list(self, *, tags: list[str] | None = None) -> list[dict[str, Any]]:
85
- """List all named queries, optionally filtered by tags.
86
-
87
- Returns metadata only (no ``query_content``) for lightweight
88
- listing. Each entry is a dict with: ``query_key``, ``title``,
89
- ``description``, ``tags``, ``language``, ``updated_at``.
90
-
91
- Args:
92
- tags: If provided, only return queries whose tags overlap
93
- with at least one of the given tags.
94
- """
95
- result = self._cdb.execute(
96
- "MATCH (q:_NamedQuery) RETURN q ORDER BY q.query_key",
97
- unnest_result=True,
98
- )
99
- if not result:
100
- return []
101
-
102
- nodes = result if isinstance(result, list) else [result]
103
- entries = []
104
- for node in nodes:
105
- if not isinstance(node, NamedQueryNode):
106
- continue
107
- node_tags = _deserialize_tags(node.tags)
108
- if tags and not set(tags) & set(node_tags):
109
- continue
110
- entries.append(
111
- {
112
- "query_key": node.query_key,
113
- "title": node.title,
114
- "description": node.description,
115
- "tags": node_tags,
116
- "language": node.language,
117
- "updated_at": node.updated_at,
118
- }
119
- )
120
- return entries
121
-
122
- # -- load ----------------------------------------------------------------
123
-
124
- def load(self, query_key: str) -> dict[str, Any] | None:
125
- """Load full query detail including query_content.
126
-
127
- Returns a dict with all properties, or None if not found.
128
- The ``tags``, ``params_schema``, and ``ui_schema`` fields
129
- are deserialized from JSON strings.
130
- """
131
- node = self._load_node(query_key)
132
- if node is None:
133
- return None
134
-
135
- return {
136
- "query_key": node.query_key,
137
- "title": node.title,
138
- "description": node.description,
139
- "tags": _deserialize_tags(node.tags),
140
- "query_content": node.query_content,
141
- "language": node.language,
142
- "params_schema": _deserialize_json(node.params_schema),
143
- "ui_schema": _deserialize_json(node.ui_schema),
144
- "updated_at": node.updated_at,
145
- }
146
-
147
- def _load_node(self, query_key: str) -> NamedQueryNode | None:
148
- """Load the raw NamedQueryNode by query_key, or None."""
149
- result = self._cdb.execute(
150
- "MATCH (q:_NamedQuery {query_key: $key}) RETURN q",
151
- params={"key": query_key},
152
- unnest_result=True,
153
- fetch_one=True,
154
- )
155
- if not result or not isinstance(result, NamedQueryNode):
156
- return None
157
- return result
158
-
159
- # -- save ----------------------------------------------------------------
160
-
161
- def save(
162
- self,
163
- query_key: str,
164
- *,
165
- title: str | None = None,
166
- description: str | None = None,
167
- tags: builtins.list[str] | None = None,
168
- query_content: str | None = None,
169
- language: str | None = None,
170
- params_schema: dict | None = None,
171
- ui_schema: dict | None = None,
172
- ) -> None:
173
- """Create or update a named query.
174
-
175
- Fetches the existing node (if any), updates only the provided
176
- (non-None) fields, and persists via ``create_or_merge``. New
177
- nodes get a ``gid_`` automatically.
178
-
179
- Args:
180
- query_key: Unique identifier (kebab-case).
181
- title: Display name.
182
- description: What the query does.
183
- tags: Categorization tags (serialized to JSON).
184
- query_content: The query text (Cypher, Jinja2, or Datalog).
185
- language: ``"cypher"``, ``"jinja2"``, or ``"datalog"``.
186
- params_schema: JSON Schema dict for parameter validation.
187
- ui_schema: UI rendering hints dict for sonnet-ui.
188
- """
189
- existing = self._load_node(query_key)
190
- node = existing if existing is not None else NamedQueryNode(query_key=query_key)
191
-
192
- # Update only non-None fields (partial update)
193
- if title is not None:
194
- node.title = title
195
- if description is not None:
196
- node.description = description
197
- if tags is not None:
198
- node.tags = json.dumps(tags, ensure_ascii=False)
199
- if query_content is not None:
200
- node.query_content = query_content
201
- if language is not None:
202
- node.language = language
203
- if params_schema is not None:
204
- node.params_schema = json.dumps(params_schema, ensure_ascii=False)
205
- if ui_schema is not None:
206
- node.ui_schema = json.dumps(ui_schema, ensure_ascii=False)
207
-
208
- node.updated_at = arrow.utcnow().isoformat()
209
-
210
- self._cdb.create_or_merge(node)
211
-
212
- # -- remove --------------------------------------------------------------
213
-
214
- def remove(self, query_key: str) -> bool:
215
- """Remove a named query. Returns True if it existed."""
216
- existing = self._load_node(query_key)
217
- if existing is None:
218
- return False
219
- self._cdb.delete(existing)
220
- return True
221
-
222
- def remove_all(self) -> int:
223
- """Remove all named queries. Returns count removed.
224
-
225
- Used for clean re-seeding.
226
- """
227
- result = self._cdb.execute(
228
- "MATCH (q:_NamedQuery) RETURN count(q)",
229
- unnest_result=True,
230
- fetch_one=True,
231
- )
232
- count = int(result) if result else 0
233
- if count > 0:
234
- self._cdb.execute("MATCH (q:_NamedQuery) DELETE q")
235
- return count
236
-
237
-
238
- # -- helpers -----------------------------------------------------------------
239
-
240
-
241
- def _deserialize_tags(tags_str: str | None) -> list[str]:
242
- """Deserialize tags from JSON string to list."""
243
- if not tags_str:
244
- return []
245
- try:
246
- parsed = json.loads(tags_str)
247
- if isinstance(parsed, list):
248
- return parsed
249
- except json.JSONDecodeError, TypeError:
250
- pass
251
- return []
252
-
253
-
254
- def _deserialize_json(value: str | None) -> dict | None:
255
- """Deserialize a JSON string to dict, or None."""
256
- if not value:
257
- return None
258
- try:
259
- parsed = json.loads(value)
260
- if isinstance(parsed, dict):
261
- return parsed
262
- except json.JSONDecodeError, TypeError:
263
- pass
264
- return None
@@ -1,170 +0,0 @@
1
- """Graph metadata initialization and query seeding utilities.
2
-
3
- Two separate concerns:
4
-
5
- ``init_graph_metadata(cdb, ...)`` -- initialize model and description
6
- on the :_GraphModel node. One-time setup, called by
7
- ``lauro graph init``. Raises on failure.
8
-
9
- ``seed_named_queries(cdb, queries_dir)`` -- seed :_NamedQuery nodes from
10
- file pairs (.json + .cypher/.jinja/.dl). Data seeding, idempotent
11
- via MERGE. Returns ``QuerySeedResult`` with saved/skipped counts.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import json
17
- from dataclasses import dataclass, field
18
- from pathlib import Path
19
-
20
- from cypher_graphdb import CypherGraphDB
21
- from loguru import logger
22
-
23
- from sonnet_graph.metadata.model_store import GraphModelStore
24
- from sonnet_graph.metadata.query_store import GraphQueryStore
25
-
26
- # File extensions mapped to query language.
27
- _QUERY_EXTENSIONS: dict[str, str] = {
28
- ".cypher": "cypher",
29
- ".jinja": "jinja2",
30
- ".jinja2": "jinja2",
31
- ".dl": "datalog",
32
- }
33
-
34
-
35
- @dataclass
36
- class QuerySeedResult:
37
- """Result of a ``seed_named_queries`` call.
38
-
39
- Carries the partial-failure outcome: which query files were saved
40
- and which were skipped (e.g. missing matching query file, parse
41
- error). Callers use this to log, warn, or fail.
42
- """
43
-
44
- queries_saved: list[str] = field(default_factory=list)
45
- queries_skipped: list[str] = field(default_factory=list)
46
-
47
-
48
- def init_graph_metadata(
49
- cdb: CypherGraphDB,
50
- *,
51
- model: dict | None = None,
52
- models_path: str | Path | None = None,
53
- model_name: str | None = None,
54
- model_version: str | None = None,
55
- description: str | None = None,
56
- ) -> None:
57
- """Initialize graph metadata -- model schema and description.
58
-
59
- Writes the :_GraphModel node with schema and optional description.
60
- Intended for one-time graph initialization (``lauro graph init``).
61
- Does NOT call ``commit()`` -- the caller is responsible.
62
-
63
- Args:
64
- cdb: Active CypherGraphDB connection.
65
- model: JSON Schema dict to write as :_GraphModel node.
66
- models_path: Path to a Python file or directory declaring graph
67
- models on a ModelProvider. Generates and saves the schema.
68
- Mutually exclusive with ``model``.
69
- model_name: Graph model name.
70
- model_version: Schema version string.
71
- description: Markdown string stored on the :_GraphModel node.
72
-
73
- Raises:
74
- ValueError: If both ``model`` and ``models_path`` are provided.
75
- ValueError: If ``description`` is provided without a model/models_path
76
- and no :_GraphModel node already exists in the graph. Use
77
- ``GraphModelStore.update_description()`` directly to update
78
- an existing node's description independently.
79
- """
80
- if model is not None and models_path is not None:
81
- raise ValueError("Provide either 'model' or 'models_path', not both")
82
-
83
- store = GraphModelStore(cdb)
84
-
85
- if model is not None:
86
- store.save(model, name=model_name, version=model_version)
87
- logger.debug("Initialized graph model '{}' from dict", model_name or "")
88
- elif models_path is not None:
89
- store.save_from_path(models_path, name=model_name, version=model_version)
90
- logger.debug("Initialized graph model '{}' from path '{}'", model_name or "", models_path)
91
-
92
- if description is not None:
93
- store.update_description(description)
94
- logger.debug("Updated graph description")
95
-
96
-
97
- def seed_named_queries(
98
- cdb: CypherGraphDB,
99
- queries_dir: str | Path,
100
- ) -> QuerySeedResult:
101
- """Seed :_NamedQuery nodes from file pairs in a directory.
102
-
103
- Each pair is a ``.json`` metadata file + a query file (``.cypher``,
104
- ``.jinja``, ``.jinja2``, or ``.dl``). The file stem is used as
105
- ``query_key`` if not specified in the JSON.
106
-
107
- Does NOT call ``commit()`` -- the caller is responsible.
108
-
109
- Args:
110
- cdb: Active CypherGraphDB connection.
111
- queries_dir: Directory with query file pairs.
112
-
113
- Returns:
114
- QuerySeedResult with saved and skipped query keys.
115
- """
116
- result = QuerySeedResult()
117
- _seed_queries(GraphQueryStore(cdb), Path(queries_dir), result)
118
- return result
119
-
120
-
121
- def _seed_queries(store: GraphQueryStore, queries_dir: Path, result: QuerySeedResult) -> None:
122
- """Seed named queries from file pairs in a directory."""
123
- if not queries_dir.is_dir():
124
- logger.warning("Queries directory does not exist: {}", queries_dir)
125
- return
126
-
127
- for json_path in sorted(queries_dir.glob("*.json")):
128
- stem = json_path.stem
129
- query_path = _find_query_file(queries_dir, stem)
130
- if query_path is None:
131
- logger.warning("No query file found for {}.json (expected .cypher, .jinja, .jinja2, or .dl)", stem)
132
- result.queries_skipped.append(stem)
133
- continue
134
-
135
- try:
136
- _seed_one_query(store, json_path, query_path, stem)
137
- result.queries_saved.append(stem)
138
- logger.debug("Seeded query '{}'", stem)
139
- except Exception:
140
- logger.exception("Failed to seed query '{}' from {}", stem, json_path)
141
- result.queries_skipped.append(stem)
142
-
143
-
144
- def _find_query_file(directory: Path, stem: str) -> Path | None:
145
- """Find the query file matching a JSON metadata file."""
146
- for ext in _QUERY_EXTENSIONS:
147
- candidate = directory / f"{stem}{ext}"
148
- if candidate.is_file():
149
- return candidate
150
- return None
151
-
152
-
153
- def _seed_one_query(store: GraphQueryStore, json_path: Path, query_path: Path, stem: str) -> None:
154
- """Read a file pair and save as a named query."""
155
- metadata = json.loads(json_path.read_text(encoding="utf-8"))
156
- query_content = query_path.read_text(encoding="utf-8")
157
- language = _QUERY_EXTENSIONS.get(query_path.suffix, "cypher")
158
-
159
- query_key = metadata.get("template_key") or metadata.get("query_key") or stem
160
-
161
- store.save(
162
- query_key,
163
- title=metadata.get("title"),
164
- description=metadata.get("description"),
165
- tags=metadata.get("tags"),
166
- query_content=query_content,
167
- language=language,
168
- params_schema=metadata.get("json_schema") or metadata.get("params_schema"),
169
- ui_schema=metadata.get("ui_schema"),
170
- )
File without changes
File without changes