hinge 0.1.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.
Files changed (111) hide show
  1. hinge/__init__.py +3 -0
  2. hinge/config/__init__.py +0 -0
  3. hinge/config/logging_setup.py +63 -0
  4. hinge/config/settings.py +23 -0
  5. hinge/config/types.yaml +42 -0
  6. hinge/dbt/.user.yml +1 -0
  7. hinge/dbt/dbt_project.yml +18 -0
  8. hinge/dbt/macros/apply_time_window.sql +11 -0
  9. hinge/dbt/macros/assert_recipe_supported.sql +59 -0
  10. hinge/dbt/macros/build_incidence.sql +46 -0
  11. hinge/dbt/macros/collapse_path.sql +26 -0
  12. hinge/dbt/macros/filter_bots.sql +23 -0
  13. hinge/dbt/macros/network_edges.sql +39 -0
  14. hinge/dbt/macros/project_bipartite.sql +33 -0
  15. hinge/dbt/macros/slice_edges.sql +41 -0
  16. hinge/dbt/models/hin/hin_accounts.sql +14 -0
  17. hinge/dbt/models/hin/hin_artifacts.sql +23 -0
  18. hinge/dbt/models/hin/hin_capabilities.sql +42 -0
  19. hinge/dbt/models/hin/hin_edges.sql +57 -0
  20. hinge/dbt/models/hin/hin_nodes.sql +44 -0
  21. hinge/dbt/models/hin/hin_repositories.sql +15 -0
  22. hinge/dbt/models/intermediate/int_developer_repo_affiliation.sql +27 -0
  23. hinge/dbt/models/intermediate/int_user_artifact_incidence.sql +21 -0
  24. hinge/dbt/models/networks/artifact_reference.sql +27 -0
  25. hinge/dbt/models/networks/co_commit_user_user.sql +48 -0
  26. hinge/dbt/models/networks/co_edit_file_user_user.sql +48 -0
  27. hinge/dbt/models/networks/co_edit_line_user_user.sql +48 -0
  28. hinge/dbt/models/networks/dev_interaction.sql +98 -0
  29. hinge/dbt/models/networks/developer_repo_affiliation.sql +20 -0
  30. hinge/dbt/models/networks/follow_user_user.sql +27 -0
  31. hinge/dbt/models/networks/fork_repo_repo.sql +27 -0
  32. hinge/dbt/models/networks/issue_co_participation.sql +50 -0
  33. hinge/dbt/models/networks/issue_participation.sql +40 -0
  34. hinge/dbt/models/networks/pr_author_reviewer.sql +66 -0
  35. hinge/dbt/models/networks/pr_participation.sql +40 -0
  36. hinge/dbt/models/networks/pr_reviewer_coreview.sql +48 -0
  37. hinge/dbt/models/networks/repo_shared_contributors.sql +41 -0
  38. hinge/dbt/models/networks/star_user_repo.sql +27 -0
  39. hinge/dbt/models/networks/user_mention_user.sql +60 -0
  40. hinge/dbt/models/networks/watch_user_repo.sql +27 -0
  41. hinge/dbt/models/sources/sources.yml +63 -0
  42. hinge/dbt/models/staging/stg_accounts.sql +4 -0
  43. hinge/dbt/models/staging/stg_adapter_manifest.sql +4 -0
  44. hinge/dbt/models/staging/stg_artifacts.sql +4 -0
  45. hinge/dbt/models/staging/stg_relations.sql +4 -0
  46. hinge/dbt/models/staging/stg_repositories.sql +4 -0
  47. hinge/dbt/profiles.yml +9 -0
  48. hinge/dbt/seeds/ref_recipe_requirements.csv +35 -0
  49. hinge/frontends/__init__.py +0 -0
  50. hinge/frontends/cli/__init__.py +3 -0
  51. hinge/frontends/cli/app.py +189 -0
  52. hinge/frontends/lib.py +148 -0
  53. hinge/frontends/tui/__init__.py +7 -0
  54. hinge/frontends/web/__init__.py +6 -0
  55. hinge/kernel/__init__.py +0 -0
  56. hinge/kernel/projection/__init__.py +9 -0
  57. hinge/kernel/projection/projected_graph.py +32 -0
  58. hinge/kernel/projection/projection_spec.py +35 -0
  59. hinge/kernel/protocols/__init__.py +24 -0
  60. hinge/kernel/protocols/dataset_view.py +30 -0
  61. hinge/kernel/protocols/exporter_stage.py +24 -0
  62. hinge/kernel/protocols/projection_stage.py +36 -0
  63. hinge/kernel/protocols/reader_stage.py +52 -0
  64. hinge/kernel/protocols/store_stage.py +71 -0
  65. hinge/kernel/registry.py +71 -0
  66. hinge/kernel/runner.py +236 -0
  67. hinge/kernel/schema/__init__.py +12 -0
  68. hinge/kernel/schema/hin_schema.py +38 -0
  69. hinge/kernel/schema/schema_violation.py +21 -0
  70. hinge/kernel/schema/typed_edge.py +20 -0
  71. hinge/kernel/schema/typed_node.py +17 -0
  72. hinge/stages/__init__.py +0 -0
  73. hinge/stages/exporters/__init__.py +0 -0
  74. hinge/stages/exporters/csv_exporter.py +77 -0
  75. hinge/stages/exporters/dot_exporter.py +13 -0
  76. hinge/stages/exporters/gexf_exporter.py +13 -0
  77. hinge/stages/exporters/gml_exporter.py +79 -0
  78. hinge/stages/exporters/graphml_exporter.py +13 -0
  79. hinge/stages/exporters/jsonld_exporter.py +13 -0
  80. hinge/stages/exporters/networkx_exporter.py +57 -0
  81. hinge/stages/exporters/parquet_exporter.py +13 -0
  82. hinge/stages/projection/__init__.py +0 -0
  83. hinge/stages/projection/dbt_projection.py +342 -0
  84. hinge/stages/projection/specs/__init__.py +0 -0
  85. hinge/stages/projection/specs/artifact_reference.py +14 -0
  86. hinge/stages/projection/specs/co_commit_user_user.py +14 -0
  87. hinge/stages/projection/specs/co_edit_file_user_user.py +14 -0
  88. hinge/stages/projection/specs/co_edit_line_user_user.py +14 -0
  89. hinge/stages/projection/specs/dev_interaction.py +26 -0
  90. hinge/stages/projection/specs/developer_repo_affiliation.py +15 -0
  91. hinge/stages/projection/specs/follow_user_user.py +14 -0
  92. hinge/stages/projection/specs/fork_repo_repo.py +14 -0
  93. hinge/stages/projection/specs/issue_co_participation.py +14 -0
  94. hinge/stages/projection/specs/issue_participation.py +14 -0
  95. hinge/stages/projection/specs/pr_author_reviewer.py +14 -0
  96. hinge/stages/projection/specs/pr_participation.py +14 -0
  97. hinge/stages/projection/specs/pr_reviewer_coreview.py +14 -0
  98. hinge/stages/projection/specs/repo_shared_contributors.py +14 -0
  99. hinge/stages/projection/specs/star_user_repo.py +15 -0
  100. hinge/stages/projection/specs/user_mention_user.py +14 -0
  101. hinge/stages/projection/specs/watch_user_repo.py +14 -0
  102. hinge/stages/readers/__init__.py +0 -0
  103. hinge/stages/readers/_numfocus_contract_adapter.py +314 -0
  104. hinge/stages/readers/numfocus_reader.py +69 -0
  105. hinge/stages/store/__init__.py +0 -0
  106. hinge/stages/store/duckdb_store.py +466 -0
  107. hinge-0.1.0.dist-info/METADATA +309 -0
  108. hinge-0.1.0.dist-info/RECORD +111 -0
  109. hinge-0.1.0.dist-info/WHEEL +4 -0
  110. hinge-0.1.0.dist-info/entry_points.txt +35 -0
  111. hinge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class TypedNode(BaseModel):
10
+ """A typed graph node. ``type`` is an open string label — validated against
11
+ a :class:`HINSchema` at ingest time, not constrained by a Python enum.
12
+ """
13
+
14
+ type: str
15
+ id: str
16
+ timestamp: datetime | None = None
17
+ attrs: dict[str, Any] = Field(default_factory=dict)
File without changes
File without changes
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ from io import BytesIO, StringIO
8
+ from typing import Any, BinaryIO
9
+ from zipfile import ZIP_DEFLATED, ZipFile
10
+
11
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
12
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _NODE_COLUMNS = ["id", "type", "attrs"]
17
+ _EDGE_COLUMNS = ["source", "target", "type", "weight", "attrs"]
18
+
19
+
20
+ class CsvExporter:
21
+ """Write nodes.csv and edges.csv into one zip archive."""
22
+
23
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
24
+ logger.debug("csv exporter started")
25
+ digest = hashlib.sha256()
26
+ node_count = 0
27
+ edge_count = 0
28
+
29
+ nodes = StringIO()
30
+ node_writer = csv.DictWriter(nodes, fieldnames=_NODE_COLUMNS)
31
+ node_writer.writeheader()
32
+ for node in handle.iter_nodes():
33
+ node_writer.writerow(
34
+ {
35
+ "id": node.id,
36
+ "type": node.type,
37
+ "attrs": _json_dumps(node.attrs),
38
+ }
39
+ )
40
+ node_count += 1
41
+
42
+ edges = StringIO()
43
+ edge_writer = csv.DictWriter(edges, fieldnames=_EDGE_COLUMNS)
44
+ edge_writer.writeheader()
45
+ for edge in handle.iter_edges():
46
+ attrs = dict(edge.attrs)
47
+ edge_writer.writerow(
48
+ {
49
+ "source": edge.src_id,
50
+ "target": edge.dst_id,
51
+ "type": edge.type,
52
+ "weight": attrs.get("weight", ""),
53
+ "attrs": _json_dumps(attrs),
54
+ }
55
+ )
56
+ edge_count += 1
57
+
58
+ payload = BytesIO()
59
+ with ZipFile(payload, mode="w", compression=ZIP_DEFLATED) as archive:
60
+ archive.writestr("nodes.csv", nodes.getvalue())
61
+ archive.writestr("edges.csv", edges.getvalue())
62
+
63
+ data = payload.getvalue()
64
+ sink.write(data)
65
+ digest.update(data)
66
+
67
+ return ExportReceipt(
68
+ format="csv",
69
+ content_hash=digest.hexdigest(),
70
+ schema_version=0,
71
+ node_count=node_count,
72
+ edge_count=edge_count,
73
+ )
74
+
75
+
76
+ def _json_dumps(value: dict[str, Any]) -> str:
77
+ return json.dumps(value, sort_keys=True, default=str)
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO
4
+
5
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
6
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
7
+
8
+
9
+ class DotExporter:
10
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
11
+ raise NotImplementedError(
12
+ "DotExporter is a stub. Implement before registering an entry-point."
13
+ )
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO
4
+
5
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
6
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
7
+
8
+
9
+ class GexfExporter:
10
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
11
+ raise NotImplementedError(
12
+ "GexfExporter is a stub. Implement before registering an entry-point."
13
+ )
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ from typing import BinaryIO
7
+
8
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
9
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GmlExporter:
15
+ """Stream a projected graph as GML plain-text.
16
+
17
+ Edge labels are written through unchanged — the kernel uses an open
18
+ label vocabulary, so the exporter does not need to consult any enum.
19
+ """
20
+
21
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
22
+ logger.debug("gml exporter started")
23
+ node_ids: dict[str, int] = {}
24
+ digest = hashlib.sha256()
25
+ node_count = 0
26
+ edge_count = 0
27
+
28
+ def emit(line: str) -> None:
29
+ payload = line.encode("utf-8")
30
+ sink.write(payload)
31
+ digest.update(payload)
32
+
33
+ emit("graph [\n directed 1\n")
34
+
35
+ for node in handle.iter_nodes():
36
+ idx = len(node_ids)
37
+ node_ids[node.id] = idx
38
+ emit(
39
+ f" node [\n"
40
+ f" id {idx}\n"
41
+ f" label {json.dumps(node.id)}\n"
42
+ f" type {json.dumps(node.type)}\n"
43
+ f" ]\n"
44
+ )
45
+ node_count += 1
46
+
47
+ for edge in handle.iter_edges():
48
+ if edge.src_id not in node_ids:
49
+ node_ids[edge.src_id] = len(node_ids)
50
+ emit(
51
+ f" node [\n id {node_ids[edge.src_id]}\n"
52
+ f" label {json.dumps(edge.src_id)}\n ]\n"
53
+ )
54
+ node_count += 1
55
+ if edge.dst_id not in node_ids:
56
+ node_ids[edge.dst_id] = len(node_ids)
57
+ emit(
58
+ f" node [\n id {node_ids[edge.dst_id]}\n"
59
+ f" label {json.dumps(edge.dst_id)}\n ]\n"
60
+ )
61
+ node_count += 1
62
+ emit(
63
+ f" edge [\n"
64
+ f" source {node_ids[edge.src_id]}\n"
65
+ f" target {node_ids[edge.dst_id]}\n"
66
+ f" label {json.dumps(edge.type)}\n"
67
+ f" ]\n"
68
+ )
69
+ edge_count += 1
70
+
71
+ emit("]\n")
72
+
73
+ return ExportReceipt(
74
+ format="gml",
75
+ content_hash=digest.hexdigest(),
76
+ schema_version=0, # set by runner.run_export when schema is passed
77
+ node_count=node_count,
78
+ edge_count=edge_count,
79
+ )
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO
4
+
5
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
6
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
7
+
8
+
9
+ class GraphMlExporter:
10
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
11
+ raise NotImplementedError(
12
+ "GraphMlExporter is a stub. Implement before registering an entry-point."
13
+ )
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO
4
+
5
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
6
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
7
+
8
+
9
+ class JsonLdExporter:
10
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
11
+ raise NotImplementedError(
12
+ "JsonLdExporter is a stub. Implement before registering an entry-point."
13
+ )
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ from typing import Any, BinaryIO, cast
7
+
8
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
9
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class NetworkXExporter:
15
+ """Write a projected graph as NetworkX node-link JSON."""
16
+
17
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
18
+ logger.debug("networkx exporter started")
19
+ data: dict[str, Any] = {
20
+ "directed": True,
21
+ "multigraph": True,
22
+ "graph": {},
23
+ "nodes": [],
24
+ "edges": [],
25
+ }
26
+ node_count = 0
27
+ edge_count = 0
28
+
29
+ for node in handle.iter_nodes():
30
+ attrs = _json_safe(node.attrs)
31
+ data["nodes"].append({"id": node.id, "type": node.type, **attrs})
32
+ node_count += 1
33
+
34
+ for edge in handle.iter_edges():
35
+ attrs = _json_safe(edge.attrs)
36
+ weight = attrs.get("weight")
37
+ if weight is None:
38
+ weight = 1.0
39
+ attrs["weight"] = weight
40
+ attrs["type"] = edge.type
41
+ data["edges"].append({"source": edge.src_id, "target": edge.dst_id, **attrs})
42
+ edge_count += 1
43
+
44
+ payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
45
+ sink.write(payload)
46
+
47
+ return ExportReceipt(
48
+ format="networkx",
49
+ content_hash=hashlib.sha256(payload).hexdigest(),
50
+ schema_version=0,
51
+ node_count=node_count,
52
+ edge_count=edge_count,
53
+ )
54
+
55
+
56
+ def _json_safe(attrs: dict[str, Any]) -> dict[str, Any]:
57
+ return cast(dict[str, Any], json.loads(json.dumps(attrs, default=str)))
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import BinaryIO
4
+
5
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
6
+ from hinge.kernel.protocols.exporter_stage import ExportReceipt
7
+
8
+
9
+ class ParquetExporter:
10
+ def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt:
11
+ raise NotImplementedError(
12
+ "ParquetExporter is a stub. Implement before registering an entry-point."
13
+ )
File without changes
@@ -0,0 +1,342 @@
1
+ """dbt-backed implementation of ProjectionStage.
2
+
3
+ # ─────────────────────────────────────────────────────────────────────────
4
+ # HOW PROJECTIONS GET WIRED IN (orientation for the projection contributor)
5
+ # ─────────────────────────────────────────────────────────────────────────
6
+ #
7
+ # 1. The store owns the file. Before a projection runs, the caller asks the
8
+ # store for a ``DatasetView`` (``store.scope_to_dataset(dataset_id)``).
9
+ # The store creates the ``active_hin_nodes`` and ``active_hin_edges`` views and
10
+ # returns the path + view names. This stage never opens DuckDB directly
11
+ # for setup — it only opens a read-only connection later to stream rows.
12
+ #
13
+ # 2. ``DbtProjection.run(spec, params, view)`` invokes dbt as a subprocess,
14
+ # pointed at ``view.db_path``, to materialise the model named by the spec.
15
+ #
16
+ # 3. The materialised result table MUST follow the standard network contract:
17
+ # recipe_name, recipe_version, source_node_id, source_node_type,
18
+ # target_node_id, target_node_type, directed, edge_type, weight,
19
+ # weight_kind, n_contexts, n_events, first_seen_at, last_seen_at,
20
+ # time_bin, bot_policy, properties
21
+ # The cursor handle converts that richer dbt table into TypedEdge objects
22
+ # for existing exporters. Legacy src_id/dst_id/attrs tables are still read.
23
+ #
24
+ # 4. ``run`` returns a ``ProjectedGraphHandle`` that streams rows back from
25
+ # the materialised table. The exporter consumes the handle.
26
+ #
27
+ # To add a new cookbook projection: drop a .sql file in
28
+ # ``hinge/dbt/models/networks/``, ship a module under
29
+ # ``hinge/stages/projection/specs/`` exposing a ``SPEC`` constant, register
30
+ # the entry-point in pyproject.toml under ``hinge.projection_specs``. No
31
+ # edits to this file are needed.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import logging
39
+ import os
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import tempfile
44
+ from collections.abc import Iterator
45
+ from contextlib import contextmanager
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ import duckdb
50
+
51
+ from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
52
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
53
+ from hinge.kernel.protocols.dataset_view import DatasetView
54
+ from hinge.kernel.protocols.projection_stage import EngineFingerprint
55
+ from hinge.kernel.schema.typed_edge import TypedEdge
56
+ from hinge.kernel.schema.typed_node import TypedNode
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ _DBT_PROJECT_DIR = Path(__file__).parents[2] / "dbt"
61
+ _FETCH_BATCH = 1_000
62
+ _MODEL_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
63
+ _HASH_SUFFIXES = {".sql", ".yml", ".yaml"}
64
+ _IGNORED_DBT_DIRS = {"target", "dbt_packages", "logs"}
65
+
66
+
67
+ class DbtProjection:
68
+ def __init__(
69
+ self,
70
+ custom_model_path: str | Path | None = None,
71
+ custom_model_name: str | None = None,
72
+ ) -> None:
73
+ self._custom_model_path = (
74
+ Path(custom_model_path).expanduser().resolve() if custom_model_path else None
75
+ )
76
+ if self._custom_model_path and not self._custom_model_path.is_file():
77
+ raise FileNotFoundError(self._custom_model_path)
78
+ self._custom_model_name = (
79
+ custom_model_name
80
+ if custom_model_name is not None
81
+ else self._custom_model_path.stem
82
+ if self._custom_model_path is not None
83
+ else None
84
+ )
85
+ if self._custom_model_name is not None:
86
+ _validate_model_name(self._custom_model_name)
87
+
88
+ def run(
89
+ self, spec: ProjectionSpec, params: dict[str, Any], view: DatasetView
90
+ ) -> ProjectedGraphHandle:
91
+ if self._custom_model_name is not None and spec.model_name != self._custom_model_name:
92
+ raise ValueError(
93
+ "Custom projection spec model_name must match "
94
+ f"{self._custom_model_name!r}, got {spec.model_name!r}"
95
+ )
96
+ self._invoke_dbt(spec.model_name, params, view.db_path)
97
+ return _CursorHandle(view.db_path, spec.model_name)
98
+
99
+ def fingerprint(self) -> EngineFingerprint:
100
+ h = hashlib.sha256()
101
+ _hash_project_files(h, _DBT_PROJECT_DIR)
102
+ if self._custom_model_path is not None:
103
+ h.update(b"\0custom-model-name\0")
104
+ h.update((self._custom_model_name or "").encode())
105
+ h.update(b"\0custom-model-sql\0")
106
+ h.update(self._custom_model_path.read_bytes())
107
+ return EngineFingerprint(engine="dbt-duckdb", project_hash=h.hexdigest())
108
+
109
+ # ---- internals ----
110
+
111
+ def _invoke_dbt(self, model: str, params: dict[str, Any], db_path: Path) -> None:
112
+ with self._project_dir() as project_dir:
113
+ env = os.environ.copy()
114
+ env["HINGE_STORE_PATH"] = str(db_path)
115
+ env["DBT_PROFILES_DIR"] = str(project_dir)
116
+ seed_cmd = [
117
+ "dbt",
118
+ "seed",
119
+ "--project-dir",
120
+ str(project_dir),
121
+ "--profiles-dir",
122
+ str(project_dir),
123
+ "--select",
124
+ "ref_recipe_requirements",
125
+ ]
126
+ self._run_dbt(seed_cmd, env, "seed ref_recipe_requirements")
127
+
128
+ run_cmd = [
129
+ "dbt",
130
+ "run",
131
+ "--project-dir",
132
+ str(project_dir),
133
+ "--profiles-dir",
134
+ str(project_dir),
135
+ "--select",
136
+ f"+{model}",
137
+ ]
138
+ if params:
139
+ run_cmd += ["--vars", json.dumps(params)]
140
+
141
+ self._run_dbt(run_cmd, env, f"model {model!r}")
142
+ logger.info("dbt model %r materialised successfully", model)
143
+
144
+ @contextmanager
145
+ def _project_dir(self) -> Iterator[Path]:
146
+ if self._custom_model_path is None:
147
+ yield _DBT_PROJECT_DIR
148
+ return
149
+
150
+ with tempfile.TemporaryDirectory(prefix="hinge-dbt-") as tmp:
151
+ project_dir = Path(tmp) / "dbt"
152
+ shutil.copytree(
153
+ _DBT_PROJECT_DIR,
154
+ project_dir,
155
+ ignore=shutil.ignore_patterns(*_IGNORED_DBT_DIRS),
156
+ )
157
+ custom_dir = project_dir / "models" / "custom"
158
+ custom_dir.mkdir(parents=True, exist_ok=True)
159
+ shutil.copy2(self._custom_model_path, custom_dir / f"{self._custom_model_name}.sql")
160
+ yield project_dir
161
+
162
+ def _run_dbt(self, cmd: list[str], env: dict[str, str], label: str) -> None:
163
+ logger.debug("dbt invocation: %s", " ".join(cmd))
164
+ result = subprocess.run(cmd, env=env, capture_output=True, text=True)
165
+
166
+ for line in result.stdout.splitlines():
167
+ logger.debug("[dbt] %s", line)
168
+ if result.returncode != 0:
169
+ for line in result.stderr.splitlines():
170
+ logger.debug("[dbt stderr] %s", line)
171
+ logger.error(
172
+ "dbt failed (exit %d) for %s%s",
173
+ result.returncode,
174
+ label,
175
+ _summarize_dbt_failure(result.stdout, result.stderr),
176
+ )
177
+ raise subprocess.CalledProcessError(
178
+ result.returncode, cmd, result.stdout, result.stderr
179
+ )
180
+
181
+
182
+ def _summarize_dbt_failure(stdout: str, stderr: str) -> str:
183
+ text = f"{stdout}\n{stderr}"
184
+ capability_failure = _extract_capability_failure(text)
185
+ if capability_failure:
186
+ return f": {capability_failure}"
187
+ return ""
188
+
189
+
190
+ def _extract_capability_failure(text: str) -> str | None:
191
+ for line in text.splitlines():
192
+ if "Cannot build `" not in line or "Missing capabilities:" not in line:
193
+ continue
194
+ message = line.strip()
195
+ prefix = "Cannot build `"
196
+ prefix_index = message.find(prefix)
197
+ if prefix_index >= 0:
198
+ message = message[prefix_index:]
199
+ return message
200
+ return None
201
+
202
+
203
+ def _validate_model_name(model_name: str) -> None:
204
+ if not _MODEL_NAME_RE.match(model_name):
205
+ raise ValueError(
206
+ "dbt model names must be valid identifiers: start with a letter or underscore, "
207
+ "then use only letters, digits, and underscores"
208
+ )
209
+
210
+
211
+ def _hash_project_files(h: Any, project_dir: Path) -> None:
212
+ for p in sorted(project_dir.rglob("*")):
213
+ if p.is_file() and p.suffix in _HASH_SUFFIXES and _IGNORED_DBT_DIRS.isdisjoint(p.parts):
214
+ h.update(p.relative_to(project_dir).as_posix().encode())
215
+ h.update(b"\0")
216
+ h.update(p.read_bytes())
217
+
218
+
219
+ class _CursorHandle:
220
+ """Streams a materialised projection table as TypedNodes / TypedEdges.
221
+
222
+ Real streaming — uses ``fetchmany`` so the exporter doesn't have to hold
223
+ the whole graph in memory.
224
+ """
225
+
226
+ def __init__(self, db_path: Path, table_name: str) -> None:
227
+ self._db_path = db_path
228
+ self._table_name = table_name
229
+
230
+ def iter_nodes(self) -> Iterator[TypedNode]:
231
+ conn = duckdb.connect(str(self._db_path), read_only=True)
232
+ try:
233
+ if self._uses_standard_network_schema(conn):
234
+ query = (
235
+ f"WITH endpoints AS ("
236
+ f" SELECT source_node_id AS id, source_node_type AS type FROM {self._table_name}"
237
+ f" UNION"
238
+ f" SELECT target_node_id AS id, target_node_type AS type FROM {self._table_name}"
239
+ f") "
240
+ f"SELECT e.id, e.type, to_json({{"
241
+ f" 'node_subtype': n.node_subtype,"
242
+ f" 'natural_key': n.natural_key,"
243
+ f" 'display_name': n.display_name,"
244
+ f" 'created_at': n.created_at,"
245
+ f" 'updated_at': n.updated_at,"
246
+ f" 'observed_at': n.observed_at,"
247
+ f" 'is_stub': n.is_stub,"
248
+ f" 'properties': n.properties"
249
+ f"}}) AS attrs "
250
+ f"FROM endpoints AS e "
251
+ f"LEFT JOIN hin_nodes AS n ON n.node_id = e.id"
252
+ )
253
+ else:
254
+ query = (
255
+ f"SELECT DISTINCT id, type, NULL AS attrs FROM ("
256
+ f" SELECT src_id AS id, src_type AS type FROM {self._table_name}"
257
+ f" UNION"
258
+ f" SELECT dst_id AS id, dst_type AS type FROM {self._table_name}"
259
+ f")"
260
+ )
261
+ cur = conn.execute(query)
262
+ while True:
263
+ rows = cur.fetchmany(_FETCH_BATCH)
264
+ if not rows:
265
+ break
266
+ for id_, type_, attrs in rows:
267
+ node_attrs = json.loads(attrs) if attrs else {}
268
+ if isinstance(node_attrs.get("properties"), str):
269
+ node_attrs["properties"] = json.loads(node_attrs["properties"])
270
+ yield TypedNode(type=type_, id=id_, attrs=node_attrs)
271
+ finally:
272
+ conn.close()
273
+
274
+ def iter_edges(self) -> Iterator[TypedEdge]:
275
+ conn = duckdb.connect(str(self._db_path), read_only=True)
276
+ try:
277
+ if self._uses_standard_network_schema(conn):
278
+ yield from self._iter_standard_edges(conn)
279
+ else:
280
+ yield from self._iter_legacy_edges(conn)
281
+ finally:
282
+ conn.close()
283
+
284
+ def _uses_standard_network_schema(self, conn: duckdb.DuckDBPyConnection) -> bool:
285
+ columns = {
286
+ row[1] for row in conn.execute(f"PRAGMA table_info('{self._table_name}')").fetchall()
287
+ }
288
+ return {
289
+ "source_node_id",
290
+ "source_node_type",
291
+ "target_node_id",
292
+ "target_node_type",
293
+ "recipe_name",
294
+ "properties",
295
+ }.issubset(columns)
296
+
297
+ def _iter_standard_edges(self, conn: duckdb.DuckDBPyConnection) -> Iterator[TypedEdge]:
298
+ cur = conn.execute(
299
+ f"""
300
+ SELECT
301
+ source_node_id,
302
+ target_node_id,
303
+ edge_type,
304
+ to_json({{
305
+ 'recipe_name': recipe_name,
306
+ 'recipe_version': recipe_version,
307
+ 'directed': directed,
308
+ 'weight': weight,
309
+ 'weight_kind': weight_kind,
310
+ 'n_contexts': n_contexts,
311
+ 'n_events': n_events,
312
+ 'first_seen_at': first_seen_at,
313
+ 'last_seen_at': last_seen_at,
314
+ 'time_bin': time_bin,
315
+ 'bot_policy': bot_policy
316
+ }}) AS standard_attrs,
317
+ properties
318
+ FROM {self._table_name}
319
+ """
320
+ )
321
+ while True:
322
+ rows = cur.fetchmany(_FETCH_BATCH)
323
+ if not rows:
324
+ break
325
+ for src_id, dst_id, edge_type, standard_attrs, properties in rows:
326
+ attrs = json.loads(standard_attrs) if standard_attrs else {}
327
+ attrs.update(json.loads(properties) if properties else {})
328
+ yield TypedEdge(type=edge_type, src_id=src_id, dst_id=dst_id, attrs=attrs)
329
+
330
+ def _iter_legacy_edges(self, conn: duckdb.DuckDBPyConnection) -> Iterator[TypedEdge]:
331
+ cur = conn.execute(f"SELECT src_id, dst_id, edge_type, attrs FROM {self._table_name}")
332
+ while True:
333
+ rows = cur.fetchmany(_FETCH_BATCH)
334
+ if not rows:
335
+ break
336
+ for src_id, dst_id, edge_type, attrs in rows:
337
+ yield TypedEdge(
338
+ type=edge_type,
339
+ src_id=src_id,
340
+ dst_id=dst_id,
341
+ attrs=json.loads(attrs) if attrs else {},
342
+ )
File without changes
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
4
+
5
+ SPEC = ProjectionSpec(
6
+ name="artifact-artifact-reference",
7
+ description=(
8
+ "Cookbook artifact-artifact reference network. Emits issue/PR/comment "
9
+ "reference, close, fix, duplicate, and related-to edges when collected."
10
+ ),
11
+ model_name="artifact_reference",
12
+ output_node_types=["artifact"],
13
+ output_edge_types=["references", "closes", "fixes", "duplicates", "relates_to"],
14
+ )