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,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProjectionParams(BaseModel):
|
|
9
|
+
"""Free-form key/value parameters passed to a projection at run time.
|
|
10
|
+
|
|
11
|
+
Examples: window start/end dates, minimum interaction count, repo filter.
|
|
12
|
+
Each projection documents which keys it consumes in its SQL header.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
values: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProjectionSpec(BaseModel):
|
|
19
|
+
"""Declarative description of one projection.
|
|
20
|
+
|
|
21
|
+
A spec is *metadata only*. It points the projection stage at a SQL model
|
|
22
|
+
file and tells callers what the output looks like. Specs are discovered
|
|
23
|
+
by the registry through the ``hinge.projection_specs`` entry-point group;
|
|
24
|
+
third-party packages register new projections the same way readers and
|
|
25
|
+
exporters are registered.
|
|
26
|
+
|
|
27
|
+
See ``hinge/dbt/models/networks/dev_interaction.sql`` for the
|
|
28
|
+
contract every model must satisfy.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
description: str
|
|
33
|
+
model_name: str
|
|
34
|
+
output_node_types: list[str]
|
|
35
|
+
output_edge_types: list[str]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from hinge.kernel.protocols.dataset_view import DatasetView
|
|
2
|
+
from hinge.kernel.protocols.exporter_stage import ExporterStage, ExportReceipt
|
|
3
|
+
from hinge.kernel.protocols.projection_stage import EngineFingerprint, ProjectionStage
|
|
4
|
+
from hinge.kernel.protocols.reader_stage import ReaderDescriptor, ReaderStage
|
|
5
|
+
from hinge.kernel.protocols.store_stage import (
|
|
6
|
+
DatasetMeta,
|
|
7
|
+
StoreCensus,
|
|
8
|
+
StoreFingerprint,
|
|
9
|
+
StoreStage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DatasetMeta",
|
|
14
|
+
"DatasetView",
|
|
15
|
+
"EngineFingerprint",
|
|
16
|
+
"ExportReceipt",
|
|
17
|
+
"ExporterStage",
|
|
18
|
+
"ProjectionStage",
|
|
19
|
+
"ReaderDescriptor",
|
|
20
|
+
"ReaderStage",
|
|
21
|
+
"StoreCensus",
|
|
22
|
+
"StoreFingerprint",
|
|
23
|
+
"StoreStage",
|
|
24
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DatasetView(Protocol):
|
|
8
|
+
"""Read-only handle to one dataset inside a store.
|
|
9
|
+
|
|
10
|
+
Stores produce this via :meth:`StoreStage.scope_to_dataset`; projections
|
|
11
|
+
consume it. Today every store backend ships a SQL view of the dataset so
|
|
12
|
+
the projection can read it with whatever query engine fits — the fields
|
|
13
|
+
are intentionally concrete (path + view names) rather than an abstract
|
|
14
|
+
"query handle", because every real projection backend needs either SQL
|
|
15
|
+
or row iteration anyway.
|
|
16
|
+
|
|
17
|
+
A future non-SQL store can implement this by writing its dataset out to
|
|
18
|
+
a temporary DuckDB file before returning — the contract stays one shape.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def dataset_id(self) -> str: ...
|
|
23
|
+
@property
|
|
24
|
+
def db_path(self) -> Path: ...
|
|
25
|
+
@property
|
|
26
|
+
def nodes_view(self) -> str: ...
|
|
27
|
+
@property
|
|
28
|
+
def edges_view(self) -> str: ...
|
|
29
|
+
|
|
30
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import BinaryIO, Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExportReceipt(BaseModel):
|
|
11
|
+
"""Result of one export. ``snapshot_id`` is set by the runner when both
|
|
12
|
+
store and projection fingerprints are available; otherwise it stays None.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
format: str
|
|
16
|
+
content_hash: str
|
|
17
|
+
schema_version: int
|
|
18
|
+
node_count: int
|
|
19
|
+
edge_count: int
|
|
20
|
+
snapshot_id: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExporterStage(Protocol):
|
|
24
|
+
def write(self, handle: ProjectedGraphHandle, sink: BinaryIO) -> ExportReceipt: ...
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
|
|
8
|
+
from hinge.kernel.projection.projection_spec import ProjectionSpec
|
|
9
|
+
from hinge.kernel.protocols.dataset_view import DatasetView
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EngineFingerprint(BaseModel):
|
|
13
|
+
"""Hash of the projection engine's own code (e.g. dbt project files).
|
|
14
|
+
Combined with the store fingerprint and schema version to make snapshot_id.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
engine: str
|
|
18
|
+
project_hash: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProjectionStage(Protocol):
|
|
22
|
+
"""Materialises a typed sub-graph from a dataset.
|
|
23
|
+
|
|
24
|
+
The projection takes a :class:`ProjectionSpec` (what to compute) and a
|
|
25
|
+
:class:`DatasetView` (where to read from). It never touches the store
|
|
26
|
+
directly — the store handed it everything it needs.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
self,
|
|
31
|
+
spec: ProjectionSpec,
|
|
32
|
+
params: dict[str, Any],
|
|
33
|
+
view: DatasetView,
|
|
34
|
+
) -> ProjectedGraphHandle: ...
|
|
35
|
+
|
|
36
|
+
def fingerprint(self) -> EngineFingerprint: ...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from hinge.kernel.protocols.store_stage import StoreStage
|
|
10
|
+
from hinge.kernel.schema.schema_violation import SchemaViolation
|
|
11
|
+
from hinge.kernel.schema.typed_edge import TypedEdge
|
|
12
|
+
from hinge.kernel.schema.typed_node import TypedNode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReaderDescriptor(BaseModel):
|
|
16
|
+
dataset: str
|
|
17
|
+
format: str
|
|
18
|
+
path: Path
|
|
19
|
+
byte_size: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReaderStage(Protocol):
|
|
23
|
+
"""Reads a dataset file and yields typed graph elements directly.
|
|
24
|
+
|
|
25
|
+
A reader owns both the file-format parsing logic and the domain knowledge
|
|
26
|
+
needed to map raw records to ``TypedNode`` / ``TypedEdge`` objects. It may
|
|
27
|
+
also yield ``SchemaViolation`` instances for records it cannot map — the
|
|
28
|
+
runner counts and logs them rather than aborting the ingest.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def iter_elements(self) -> Iterator[TypedNode | TypedEdge | SchemaViolation]: ...
|
|
32
|
+
def describe(self) -> ReaderDescriptor: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class BulkIngestReader(Protocol):
|
|
37
|
+
"""Reader that loads a whole dataset in one bulk operation against the store.
|
|
38
|
+
|
|
39
|
+
Implementations push records directly into the store's native engine
|
|
40
|
+
(e.g. DuckDB SQL) and return summary counts. The runner detects this
|
|
41
|
+
shape and uses it in place of the element-by-element ``ReaderStage``
|
|
42
|
+
path — useful when the file format and the store backend let us avoid
|
|
43
|
+
a per-row Python round-trip.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def describe(self) -> ReaderDescriptor: ...
|
|
47
|
+
def bulk_ingest(self, store: StoreStage, dataset_id: str) -> tuple[int, int, int]:
|
|
48
|
+
"""Load the dataset into ``store`` under ``dataset_id``.
|
|
49
|
+
|
|
50
|
+
Returns ``(records_read, nodes_written, edges_written)``.
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Iterator
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from hinge.kernel.protocols.dataset_view import DatasetView
|
|
11
|
+
from hinge.kernel.schema.typed_edge import TypedEdge
|
|
12
|
+
from hinge.kernel.schema.typed_node import TypedNode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StoreFingerprint(BaseModel):
|
|
16
|
+
"""Content hash of one dataset inside a store. Used to compute snapshot_id."""
|
|
17
|
+
|
|
18
|
+
dataset_id: str
|
|
19
|
+
schema_version: int
|
|
20
|
+
node_count: int
|
|
21
|
+
edge_count: int
|
|
22
|
+
content_hash: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StoreCensus(BaseModel):
|
|
26
|
+
"""Per-type counts for one dataset."""
|
|
27
|
+
|
|
28
|
+
nodes_by_type: dict[str, int]
|
|
29
|
+
edges_by_type: dict[str, int]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatasetMeta(BaseModel):
|
|
33
|
+
dataset_id: str
|
|
34
|
+
reader: str
|
|
35
|
+
path: str
|
|
36
|
+
ingested_at: datetime
|
|
37
|
+
node_count: int | None = None
|
|
38
|
+
edge_count: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StoreStage(Protocol):
|
|
42
|
+
"""Persists typed graph elements and surfaces them to projections.
|
|
43
|
+
|
|
44
|
+
The store is a context manager: it acquires whatever resources it needs
|
|
45
|
+
(file lock, connection, transaction) on ``__enter__`` and releases them on
|
|
46
|
+
``__exit__``. This makes the DuckDB single-writer lock explicit at the
|
|
47
|
+
call site rather than buried inside method calls.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> StoreStage: ...
|
|
51
|
+
def __exit__(
|
|
52
|
+
self,
|
|
53
|
+
exc_type: type[BaseException] | None,
|
|
54
|
+
exc: BaseException | None,
|
|
55
|
+
tb: TracebackType | None,
|
|
56
|
+
) -> None: ...
|
|
57
|
+
|
|
58
|
+
# ---- ingest ----
|
|
59
|
+
def begin_dataset(self, dataset_id: str, reader: str, path: str) -> None: ...
|
|
60
|
+
def finalise_dataset(self, dataset_id: str, node_count: int, edge_count: int) -> None: ...
|
|
61
|
+
def upsert(
|
|
62
|
+
self, dataset_id: str, nodes: Iterable[TypedNode], edges: Iterable[TypedEdge]
|
|
63
|
+
) -> int: ...
|
|
64
|
+
def discard_dataset(self, dataset_id: str) -> None: ...
|
|
65
|
+
|
|
66
|
+
# ---- read / project ----
|
|
67
|
+
def scope_to_dataset(self, dataset_id: str) -> DatasetView: ...
|
|
68
|
+
def query(self, dataset_id: str, node_types: list[str]) -> Iterator[TypedNode]: ...
|
|
69
|
+
def census(self, dataset_id: str) -> StoreCensus: ...
|
|
70
|
+
def fingerprint(self, dataset_id: str) -> StoreFingerprint: ...
|
|
71
|
+
def list_datasets(self) -> list[DatasetMeta]: ...
|
hinge/kernel/registry.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Entry-point based registry for stage implementations and projection specs.
|
|
2
|
+
|
|
3
|
+
The kernel never imports a concrete stage class or a built-in spec. At startup
|
|
4
|
+
the registry scans installed packages for the entry-point groups declared in
|
|
5
|
+
``pyproject.toml`` and exposes them by name. Frontends call ``get_<role>``
|
|
6
|
+
to obtain a stage instance and ``get_projection_spec(name)`` to look up a
|
|
7
|
+
ProjectionSpec — symmetric: every extensible thing in the system plugs in the
|
|
8
|
+
same way.
|
|
9
|
+
|
|
10
|
+
Registered groups:
|
|
11
|
+
|
|
12
|
+
``hinge.readers`` -> Reader classes
|
|
13
|
+
``hinge.stores`` -> Store classes
|
|
14
|
+
``hinge.projections`` -> Projection stage classes (engines)
|
|
15
|
+
``hinge.projection_specs`` -> ProjectionSpec instances (one per task-specific subgraph)
|
|
16
|
+
``hinge.exporters`` -> Exporter classes
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import entry_points
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from hinge.kernel.projection.projection_spec import ProjectionSpec
|
|
25
|
+
|
|
26
|
+
_GROUPS = {
|
|
27
|
+
"reader": "hinge.readers",
|
|
28
|
+
"store": "hinge.stores",
|
|
29
|
+
"projection": "hinge.projections",
|
|
30
|
+
"projection_spec": "hinge.projection_specs",
|
|
31
|
+
"exporter": "hinge.exporters",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(role: str, name: str) -> Any:
|
|
36
|
+
group = _GROUPS[role]
|
|
37
|
+
matches = [ep for ep in entry_points(group=group) if ep.name == name]
|
|
38
|
+
if not matches:
|
|
39
|
+
available = sorted(ep.name for ep in entry_points(group=group))
|
|
40
|
+
raise LookupError(f"No {role} named {name!r} registered. Available: {available}")
|
|
41
|
+
return matches[0].load()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_reader(name: str, **kwargs: Any) -> Any:
|
|
45
|
+
return _load("reader", name)(**kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_store(name: str = "duckdb", **kwargs: Any) -> Any:
|
|
49
|
+
return _load("store", name)(**kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_projection(name: str = "dbt", **kwargs: Any) -> Any:
|
|
53
|
+
return _load("projection", name)(**kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_projection_spec(name: str) -> ProjectionSpec:
|
|
57
|
+
obj = _load("projection_spec", name)
|
|
58
|
+
if not isinstance(obj, ProjectionSpec):
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"Entry-point hinge.projection_specs:{name} must resolve to a "
|
|
61
|
+
f"ProjectionSpec instance, got {type(obj).__name__}"
|
|
62
|
+
)
|
|
63
|
+
return obj
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_exporter(name: str, **kwargs: Any) -> Any:
|
|
67
|
+
return _load("exporter", name)(**kwargs)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_registered(role: str) -> list[str]:
|
|
71
|
+
return sorted(ep.name for ep in entry_points(group=_GROUPS[role]))
|
hinge/kernel/runner.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Linear pipeline runner.
|
|
2
|
+
|
|
3
|
+
Pipeline shapes supported:
|
|
4
|
+
|
|
5
|
+
Reader → Store (ingest)
|
|
6
|
+
Store → Projection (project)
|
|
7
|
+
Store → Projection → Exporter (export)
|
|
8
|
+
|
|
9
|
+
The runner owns three responsibilities:
|
|
10
|
+
|
|
11
|
+
* batching and schema validation during ingest;
|
|
12
|
+
* transactional bookkeeping — a failed ingest leaves no half-written dataset;
|
|
13
|
+
* snapshot_id assembly during export, computed from store + projection
|
|
14
|
+
fingerprints + the HIN schema version + the tool version.
|
|
15
|
+
|
|
16
|
+
Stage implementations are passed in; the runner never constructs them or
|
|
17
|
+
imports concrete classes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import logging
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any, BinaryIO
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
from hinge.kernel.projection.projected_graph import ProjectedGraphHandle
|
|
30
|
+
from hinge.kernel.projection.projection_spec import ProjectionSpec
|
|
31
|
+
from hinge.kernel.protocols.dataset_view import DatasetView
|
|
32
|
+
from hinge.kernel.protocols.exporter_stage import ExporterStage, ExportReceipt
|
|
33
|
+
from hinge.kernel.protocols.projection_stage import ProjectionStage
|
|
34
|
+
from hinge.kernel.protocols.reader_stage import BulkIngestReader, ReaderStage
|
|
35
|
+
from hinge.kernel.protocols.store_stage import StoreStage
|
|
36
|
+
from hinge.kernel.schema.hin_schema import HINSchema
|
|
37
|
+
from hinge.kernel.schema.schema_violation import SchemaViolation
|
|
38
|
+
from hinge.kernel.schema.typed_edge import TypedEdge
|
|
39
|
+
from hinge.kernel.schema.typed_node import TypedNode
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_TOOL_VERSION = "0.1.0"
|
|
44
|
+
_BATCH_SIZE = 100_000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class IngestReport(BaseModel):
|
|
48
|
+
dataset_id: str
|
|
49
|
+
elements_read: int
|
|
50
|
+
nodes_upserted: int
|
|
51
|
+
edges_upserted: int
|
|
52
|
+
violation_count: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_ingest(
|
|
56
|
+
reader: ReaderStage | BulkIngestReader, store: StoreStage, schema: HINSchema
|
|
57
|
+
) -> IngestReport:
|
|
58
|
+
"""Read a dataset and persist it. Transactional: on any exception the
|
|
59
|
+
half-written dataset is discarded so the store stays consistent.
|
|
60
|
+
|
|
61
|
+
Readers that implement ``bulk_ingest`` are dispatched through the bulk
|
|
62
|
+
path — schema validation is delegated to the reader because the records
|
|
63
|
+
never cross the Python boundary.
|
|
64
|
+
"""
|
|
65
|
+
dataset_id = uuid.uuid4().hex
|
|
66
|
+
desc = reader.describe()
|
|
67
|
+
logger.info(
|
|
68
|
+
"ingest started — dataset_id=%s reader=%s format=%s path=%s size=%.1fMB",
|
|
69
|
+
dataset_id,
|
|
70
|
+
desc.dataset,
|
|
71
|
+
desc.format,
|
|
72
|
+
desc.path,
|
|
73
|
+
desc.byte_size / 1_048_576,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
store.begin_dataset(dataset_id, desc.dataset, str(desc.path))
|
|
77
|
+
if isinstance(reader, BulkIngestReader):
|
|
78
|
+
try:
|
|
79
|
+
records, nodes_total, edges_total = reader.bulk_ingest(store, dataset_id)
|
|
80
|
+
store.finalise_dataset(dataset_id, nodes_total, edges_total)
|
|
81
|
+
except BaseException:
|
|
82
|
+
logger.exception("ingest failed — discarding dataset_id=%s", dataset_id)
|
|
83
|
+
store.discard_dataset(dataset_id)
|
|
84
|
+
raise
|
|
85
|
+
logger.info(
|
|
86
|
+
"ingest complete (bulk) — dataset_id=%s nodes=%d edges=%d",
|
|
87
|
+
dataset_id,
|
|
88
|
+
nodes_total,
|
|
89
|
+
edges_total,
|
|
90
|
+
)
|
|
91
|
+
return IngestReport(
|
|
92
|
+
dataset_id=dataset_id,
|
|
93
|
+
elements_read=records,
|
|
94
|
+
nodes_upserted=nodes_total,
|
|
95
|
+
edges_upserted=edges_total,
|
|
96
|
+
violation_count=0,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
nodes_total = 0
|
|
100
|
+
edges_total = 0
|
|
101
|
+
violation_count = 0
|
|
102
|
+
elements_read = 0
|
|
103
|
+
node_buf: list[TypedNode] = []
|
|
104
|
+
edge_buf: list[TypedEdge] = []
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
for element in reader.iter_elements():
|
|
108
|
+
elements_read += 1
|
|
109
|
+
if isinstance(element, SchemaViolation):
|
|
110
|
+
violation_count += 1
|
|
111
|
+
logger.warning(
|
|
112
|
+
"violation [%s] %s:%d — %s",
|
|
113
|
+
element.code,
|
|
114
|
+
element.source,
|
|
115
|
+
element.line,
|
|
116
|
+
element.message,
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(element, TypedNode):
|
|
120
|
+
if not schema.allows_node(element.type):
|
|
121
|
+
violation_count += 1
|
|
122
|
+
logger.warning(
|
|
123
|
+
"violation [invalid_node_type] node id=%s — type %r not in schema",
|
|
124
|
+
element.id,
|
|
125
|
+
element.type,
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
node_buf.append(element)
|
|
129
|
+
elif isinstance(element, TypedEdge):
|
|
130
|
+
if not schema.allows_edge(element.type):
|
|
131
|
+
violation_count += 1
|
|
132
|
+
logger.warning(
|
|
133
|
+
"violation [invalid_edge_type] %s→%s — type %r not in schema",
|
|
134
|
+
element.src_id,
|
|
135
|
+
element.dst_id,
|
|
136
|
+
element.type,
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
edge_buf.append(element)
|
|
140
|
+
|
|
141
|
+
if len(node_buf) + len(edge_buf) >= _BATCH_SIZE:
|
|
142
|
+
store.upsert(dataset_id, node_buf, edge_buf)
|
|
143
|
+
nodes_total += len(node_buf)
|
|
144
|
+
edges_total += len(edge_buf)
|
|
145
|
+
logger.debug(
|
|
146
|
+
"batch upserted — nodes=%d edges=%d (running total nodes=%d edges=%d)",
|
|
147
|
+
len(node_buf),
|
|
148
|
+
len(edge_buf),
|
|
149
|
+
nodes_total,
|
|
150
|
+
edges_total,
|
|
151
|
+
)
|
|
152
|
+
node_buf.clear()
|
|
153
|
+
edge_buf.clear()
|
|
154
|
+
|
|
155
|
+
if node_buf or edge_buf:
|
|
156
|
+
store.upsert(dataset_id, node_buf, edge_buf)
|
|
157
|
+
nodes_total += len(node_buf)
|
|
158
|
+
edges_total += len(edge_buf)
|
|
159
|
+
|
|
160
|
+
store.finalise_dataset(dataset_id, nodes_total, edges_total)
|
|
161
|
+
except BaseException:
|
|
162
|
+
logger.exception("ingest failed — discarding dataset_id=%s", dataset_id)
|
|
163
|
+
store.discard_dataset(dataset_id)
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
logger.info(
|
|
167
|
+
"ingest complete — dataset_id=%s nodes=%d edges=%d violations=%d",
|
|
168
|
+
dataset_id,
|
|
169
|
+
nodes_total,
|
|
170
|
+
edges_total,
|
|
171
|
+
violation_count,
|
|
172
|
+
)
|
|
173
|
+
return IngestReport(
|
|
174
|
+
dataset_id=dataset_id,
|
|
175
|
+
elements_read=elements_read,
|
|
176
|
+
nodes_upserted=nodes_total,
|
|
177
|
+
edges_upserted=edges_total,
|
|
178
|
+
violation_count=violation_count,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_projection(
|
|
183
|
+
projection: ProjectionStage,
|
|
184
|
+
spec: ProjectionSpec,
|
|
185
|
+
view: DatasetView,
|
|
186
|
+
params: dict[str, Any] | None = None,
|
|
187
|
+
) -> ProjectedGraphHandle:
|
|
188
|
+
logger.info(
|
|
189
|
+
"projection started — spec=%s dataset_id=%s params=%s",
|
|
190
|
+
spec.name,
|
|
191
|
+
view.dataset_id,
|
|
192
|
+
params or {},
|
|
193
|
+
)
|
|
194
|
+
handle = projection.run(spec, params or {}, view)
|
|
195
|
+
logger.info("projection complete — spec=%s dataset_id=%s", spec.name, view.dataset_id)
|
|
196
|
+
return handle
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_export(
|
|
200
|
+
handle: ProjectedGraphHandle,
|
|
201
|
+
exporter: ExporterStage,
|
|
202
|
+
sink: BinaryIO,
|
|
203
|
+
*,
|
|
204
|
+
store: StoreStage | None = None,
|
|
205
|
+
projection: ProjectionStage | None = None,
|
|
206
|
+
schema: HINSchema | None = None,
|
|
207
|
+
dataset_id: str | None = None,
|
|
208
|
+
) -> ExportReceipt:
|
|
209
|
+
"""Write a projected graph. When store, projection, schema, and dataset_id
|
|
210
|
+
are all provided the receipt carries a snapshot_id; otherwise it doesn't.
|
|
211
|
+
"""
|
|
212
|
+
logger.info("export started")
|
|
213
|
+
receipt = exporter.write(handle, sink)
|
|
214
|
+
if schema is not None:
|
|
215
|
+
receipt = receipt.model_copy(update={"schema_version": schema.schema_version})
|
|
216
|
+
if store and projection and schema and dataset_id:
|
|
217
|
+
receipt = receipt.model_copy(
|
|
218
|
+
update={"snapshot_id": _snapshot_id(store, projection, schema, dataset_id)}
|
|
219
|
+
)
|
|
220
|
+
logger.info(
|
|
221
|
+
"export complete — format=%s nodes=%d edges=%d snapshot=%s",
|
|
222
|
+
receipt.format,
|
|
223
|
+
receipt.node_count,
|
|
224
|
+
receipt.edge_count,
|
|
225
|
+
(receipt.snapshot_id or "")[:12] or "—",
|
|
226
|
+
)
|
|
227
|
+
return receipt
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _snapshot_id(
|
|
231
|
+
store: StoreStage, projection: ProjectionStage, schema: HINSchema, dataset_id: str
|
|
232
|
+
) -> str:
|
|
233
|
+
sf = store.fingerprint(dataset_id)
|
|
234
|
+
ef = projection.fingerprint()
|
|
235
|
+
parts = f"{sf.content_hash}|{ef.project_hash}|{schema.schema_version}|{_TOOL_VERSION}"
|
|
236
|
+
return hashlib.sha256(parts.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from hinge.kernel.schema.hin_schema import HINSchema
|
|
2
|
+
from hinge.kernel.schema.schema_violation import SchemaViolation, SchemaViolationError
|
|
3
|
+
from hinge.kernel.schema.typed_edge import TypedEdge
|
|
4
|
+
from hinge.kernel.schema.typed_node import TypedNode
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"HINSchema",
|
|
8
|
+
"SchemaViolation",
|
|
9
|
+
"SchemaViolationError",
|
|
10
|
+
"TypedEdge",
|
|
11
|
+
"TypedNode",
|
|
12
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HINSchema(BaseModel):
|
|
11
|
+
"""The vocabulary of node and edge labels permitted at ingest.
|
|
12
|
+
|
|
13
|
+
Loaded from ``hinge/config/types.yaml`` — the single source of truth.
|
|
14
|
+
Labels are open strings; the schema simply enumerates which ones are
|
|
15
|
+
accepted. Projections may introduce labels not listed here; they only
|
|
16
|
+
bind ingest-time validation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
schema_version: int
|
|
20
|
+
node_types: set[str] = Field(default_factory=set)
|
|
21
|
+
edge_types: set[str] = Field(default_factory=set)
|
|
22
|
+
artifact_subtypes: set[str] = Field(default_factory=set)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_yaml(cls, path: Path) -> HINSchema:
|
|
26
|
+
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
27
|
+
return cls(
|
|
28
|
+
schema_version=int(raw["schema_version"]),
|
|
29
|
+
node_types=set(raw.get("node_types", [])),
|
|
30
|
+
edge_types=set(raw.get("edge_types", [])),
|
|
31
|
+
artifact_subtypes=set(raw.get("artifact_subtypes", [])),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def allows_node(self, label: str) -> bool:
|
|
35
|
+
return label in self.node_types
|
|
36
|
+
|
|
37
|
+
def allows_edge(self, label: str) -> bool:
|
|
38
|
+
return label in self.edge_types
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SchemaViolation(BaseModel):
|
|
7
|
+
code: str
|
|
8
|
+
message: str
|
|
9
|
+
source: str
|
|
10
|
+
line: int
|
|
11
|
+
|
|
12
|
+
def as_exception(self) -> SchemaViolationError:
|
|
13
|
+
return SchemaViolationError(self)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SchemaViolationError(Exception):
|
|
17
|
+
def __init__(self, violation: SchemaViolation) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"[{violation.code}] {violation.source}:{violation.line} — {violation.message}"
|
|
20
|
+
)
|
|
21
|
+
self.violation = violation
|
|
@@ -0,0 +1,20 @@
|
|
|
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 TypedEdge(BaseModel):
|
|
10
|
+
"""A typed graph edge. ``type`` is an open string label — projections may
|
|
11
|
+
introduce new labels (e.g. ``"interacted_with"``) without touching any enum.
|
|
12
|
+
Validated against :class:`HINSchema` only at the ingest boundary; downstream
|
|
13
|
+
stages treat labels as opaque strings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
type: str
|
|
17
|
+
src_id: str
|
|
18
|
+
dst_id: str
|
|
19
|
+
timestamp: datetime | None = None
|
|
20
|
+
attrs: dict[str, Any] = Field(default_factory=dict)
|