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.
- hinge/__init__.py +3 -0
- hinge/config/__init__.py +0 -0
- hinge/config/logging_setup.py +63 -0
- hinge/config/settings.py +23 -0
- hinge/config/types.yaml +42 -0
- hinge/dbt/.user.yml +1 -0
- hinge/dbt/dbt_project.yml +18 -0
- hinge/dbt/macros/apply_time_window.sql +11 -0
- hinge/dbt/macros/assert_recipe_supported.sql +59 -0
- hinge/dbt/macros/build_incidence.sql +46 -0
- hinge/dbt/macros/collapse_path.sql +26 -0
- hinge/dbt/macros/filter_bots.sql +23 -0
- hinge/dbt/macros/network_edges.sql +39 -0
- hinge/dbt/macros/project_bipartite.sql +33 -0
- hinge/dbt/macros/slice_edges.sql +41 -0
- hinge/dbt/models/hin/hin_accounts.sql +14 -0
- hinge/dbt/models/hin/hin_artifacts.sql +23 -0
- hinge/dbt/models/hin/hin_capabilities.sql +42 -0
- hinge/dbt/models/hin/hin_edges.sql +57 -0
- hinge/dbt/models/hin/hin_nodes.sql +44 -0
- hinge/dbt/models/hin/hin_repositories.sql +15 -0
- hinge/dbt/models/intermediate/int_developer_repo_affiliation.sql +27 -0
- hinge/dbt/models/intermediate/int_user_artifact_incidence.sql +21 -0
- hinge/dbt/models/networks/artifact_reference.sql +27 -0
- hinge/dbt/models/networks/co_commit_user_user.sql +48 -0
- hinge/dbt/models/networks/co_edit_file_user_user.sql +48 -0
- hinge/dbt/models/networks/co_edit_line_user_user.sql +48 -0
- hinge/dbt/models/networks/dev_interaction.sql +98 -0
- hinge/dbt/models/networks/developer_repo_affiliation.sql +20 -0
- hinge/dbt/models/networks/follow_user_user.sql +27 -0
- hinge/dbt/models/networks/fork_repo_repo.sql +27 -0
- hinge/dbt/models/networks/issue_co_participation.sql +50 -0
- hinge/dbt/models/networks/issue_participation.sql +40 -0
- hinge/dbt/models/networks/pr_author_reviewer.sql +66 -0
- hinge/dbt/models/networks/pr_participation.sql +40 -0
- hinge/dbt/models/networks/pr_reviewer_coreview.sql +48 -0
- hinge/dbt/models/networks/repo_shared_contributors.sql +41 -0
- hinge/dbt/models/networks/star_user_repo.sql +27 -0
- hinge/dbt/models/networks/user_mention_user.sql +60 -0
- hinge/dbt/models/networks/watch_user_repo.sql +27 -0
- hinge/dbt/models/sources/sources.yml +63 -0
- hinge/dbt/models/staging/stg_accounts.sql +4 -0
- hinge/dbt/models/staging/stg_adapter_manifest.sql +4 -0
- hinge/dbt/models/staging/stg_artifacts.sql +4 -0
- hinge/dbt/models/staging/stg_relations.sql +4 -0
- hinge/dbt/models/staging/stg_repositories.sql +4 -0
- hinge/dbt/profiles.yml +9 -0
- hinge/dbt/seeds/ref_recipe_requirements.csv +35 -0
- hinge/frontends/__init__.py +0 -0
- hinge/frontends/cli/__init__.py +3 -0
- hinge/frontends/cli/app.py +189 -0
- hinge/frontends/lib.py +148 -0
- hinge/frontends/tui/__init__.py +7 -0
- hinge/frontends/web/__init__.py +6 -0
- hinge/kernel/__init__.py +0 -0
- hinge/kernel/projection/__init__.py +9 -0
- hinge/kernel/projection/projected_graph.py +32 -0
- hinge/kernel/projection/projection_spec.py +35 -0
- hinge/kernel/protocols/__init__.py +24 -0
- hinge/kernel/protocols/dataset_view.py +30 -0
- hinge/kernel/protocols/exporter_stage.py +24 -0
- hinge/kernel/protocols/projection_stage.py +36 -0
- hinge/kernel/protocols/reader_stage.py +52 -0
- hinge/kernel/protocols/store_stage.py +71 -0
- hinge/kernel/registry.py +71 -0
- hinge/kernel/runner.py +236 -0
- hinge/kernel/schema/__init__.py +12 -0
- hinge/kernel/schema/hin_schema.py +38 -0
- hinge/kernel/schema/schema_violation.py +21 -0
- hinge/kernel/schema/typed_edge.py +20 -0
- hinge/kernel/schema/typed_node.py +17 -0
- hinge/stages/__init__.py +0 -0
- hinge/stages/exporters/__init__.py +0 -0
- hinge/stages/exporters/csv_exporter.py +77 -0
- hinge/stages/exporters/dot_exporter.py +13 -0
- hinge/stages/exporters/gexf_exporter.py +13 -0
- hinge/stages/exporters/gml_exporter.py +79 -0
- hinge/stages/exporters/graphml_exporter.py +13 -0
- hinge/stages/exporters/jsonld_exporter.py +13 -0
- hinge/stages/exporters/networkx_exporter.py +57 -0
- hinge/stages/exporters/parquet_exporter.py +13 -0
- hinge/stages/projection/__init__.py +0 -0
- hinge/stages/projection/dbt_projection.py +342 -0
- hinge/stages/projection/specs/__init__.py +0 -0
- hinge/stages/projection/specs/artifact_reference.py +14 -0
- hinge/stages/projection/specs/co_commit_user_user.py +14 -0
- hinge/stages/projection/specs/co_edit_file_user_user.py +14 -0
- hinge/stages/projection/specs/co_edit_line_user_user.py +14 -0
- hinge/stages/projection/specs/dev_interaction.py +26 -0
- hinge/stages/projection/specs/developer_repo_affiliation.py +15 -0
- hinge/stages/projection/specs/follow_user_user.py +14 -0
- hinge/stages/projection/specs/fork_repo_repo.py +14 -0
- hinge/stages/projection/specs/issue_co_participation.py +14 -0
- hinge/stages/projection/specs/issue_participation.py +14 -0
- hinge/stages/projection/specs/pr_author_reviewer.py +14 -0
- hinge/stages/projection/specs/pr_participation.py +14 -0
- hinge/stages/projection/specs/pr_reviewer_coreview.py +14 -0
- hinge/stages/projection/specs/repo_shared_contributors.py +14 -0
- hinge/stages/projection/specs/star_user_repo.py +15 -0
- hinge/stages/projection/specs/user_mention_user.py +14 -0
- hinge/stages/projection/specs/watch_user_repo.py +14 -0
- hinge/stages/readers/__init__.py +0 -0
- hinge/stages/readers/_numfocus_contract_adapter.py +314 -0
- hinge/stages/readers/numfocus_reader.py +69 -0
- hinge/stages/store/__init__.py +0 -0
- hinge/stages/store/duckdb_store.py +466 -0
- hinge-0.1.0.dist-info/METADATA +309 -0
- hinge-0.1.0.dist-info/RECORD +111 -0
- hinge-0.1.0.dist-info/WHEEL +4 -0
- hinge-0.1.0.dist-info/entry_points.txt +35 -0
- 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)
|
hinge/stages/__init__.py
ADDED
|
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
|
+
)
|