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,27 @@
1
+ {{ config(materialized='table') }}
2
+
3
+ {{ assert_recipe_supported('star_user_repo') }}
4
+
5
+ WITH starred_edges AS (
6
+ {{ slice_edges(
7
+ edge_types=['starred'],
8
+ source_types=['user'],
9
+ target_types=['repo']
10
+ ) }}
11
+ )
12
+
13
+ {{ network_edges(
14
+ relation='starred_edges',
15
+ recipe_name='star_user_repo',
16
+ source_node_id='source_node_id',
17
+ source_node_type="'user'",
18
+ target_node_id='target_node_id',
19
+ target_node_type="'repo'",
20
+ edge_type="'starred'",
21
+ directed='true',
22
+ weight='coalesce(weight, 1.0)',
23
+ weight_kind="'binary'",
24
+ first_seen_at='occurred_at',
25
+ last_seen_at='occurred_at',
26
+ properties="to_json({'source_record_id': source_record_id})"
27
+ ) }}
@@ -0,0 +1,60 @@
1
+ {{ config(materialized='table') }}
2
+
3
+ {{ assert_recipe_supported('user_mention_user') }}
4
+
5
+ WITH authored_comments AS (
6
+ SELECT
7
+ source_node_id AS author_node_id,
8
+ target_node_id AS comment_node_id,
9
+ min(occurred_at) AS first_seen_at,
10
+ max(occurred_at) AS last_seen_at
11
+ FROM {{ ref('hin_edges') }}
12
+ WHERE source_node_type = 'user'
13
+ AND target_node_type = 'artifact'
14
+ AND target_node_subtype IN ('comment', 'issue_comment', 'review_comment', 'commit_comment')
15
+ AND edge_type IN ('authored', 'commented_on', 'review_commented_on')
16
+ GROUP BY source_node_id, target_node_id
17
+ ),
18
+
19
+ comment_mentions AS (
20
+ SELECT
21
+ source_node_id AS comment_node_id,
22
+ target_node_id AS mentioned_user_node_id,
23
+ min(occurred_at) AS first_seen_at,
24
+ max(occurred_at) AS last_seen_at
25
+ FROM {{ ref('hin_edges') }}
26
+ WHERE source_node_type = 'artifact'
27
+ AND source_node_subtype IN ('comment', 'issue_comment', 'review_comment', 'commit_comment')
28
+ AND target_node_type = 'user'
29
+ AND edge_type = 'mentions'
30
+ GROUP BY source_node_id, target_node_id
31
+ ),
32
+
33
+ collapsed AS (
34
+ {{ collapse_two_hop_path(
35
+ first_relation='authored_comments',
36
+ second_relation='comment_mentions',
37
+ source_col='author_node_id',
38
+ mediator_col='comment_node_id',
39
+ second_source_col='comment_node_id',
40
+ target_col='mentioned_user_node_id',
41
+ directed=true
42
+ ) }}
43
+ )
44
+
45
+ {{ network_edges(
46
+ relation='collapsed',
47
+ recipe_name='user_mention_user',
48
+ source_node_id='source_node_id',
49
+ source_node_type="'user'",
50
+ target_node_id='target_node_id',
51
+ target_node_type="'user'",
52
+ edge_type="'mentions_user'",
53
+ directed='true',
54
+ weight='n_contexts',
55
+ weight_kind="'count'",
56
+ n_contexts='n_contexts',
57
+ first_seen_at='first_seen_at',
58
+ last_seen_at='last_seen_at',
59
+ properties="to_json({'comments': context_node_ids})"
60
+ ) }}
@@ -0,0 +1,27 @@
1
+ {{ config(materialized='table') }}
2
+
3
+ {{ assert_recipe_supported('watch_user_repo') }}
4
+
5
+ WITH watch_edges AS (
6
+ {{ slice_edges(
7
+ edge_types=['watches'],
8
+ source_types=['user'],
9
+ target_types=['repo']
10
+ ) }}
11
+ )
12
+
13
+ {{ network_edges(
14
+ relation='watch_edges',
15
+ recipe_name='watch_user_repo',
16
+ source_node_id='source_node_id',
17
+ source_node_type="'user'",
18
+ target_node_id='target_node_id',
19
+ target_node_type="'repo'",
20
+ edge_type="'watches'",
21
+ directed='true',
22
+ weight='coalesce(weight, 1.0)',
23
+ weight_kind="'binary'",
24
+ first_seen_at='occurred_at',
25
+ last_seen_at='occurred_at',
26
+ properties="to_json({'source_record_id': source_record_id})"
27
+ ) }}
@@ -0,0 +1,63 @@
1
+ # dbt source declaration.
2
+ #
3
+ # DbtProjection creates dataset-scoped views before every dbt run.
4
+ #
5
+ # Core HIN projections should reference:
6
+ # {{ source('hin', 'active_hin_nodes') }}
7
+ # {{ source('hin', 'active_hin_edges') }}
8
+ #
9
+ # The legacy active_nodes / active_edges sources remain for old projections only.
10
+ # Never reference base tables directly — they contain all datasets.
11
+ version: 2
12
+
13
+ sources:
14
+ - name: hin
15
+ schema: main
16
+ tables:
17
+ - name: active_nodes
18
+ description: "View over nodes filtered to the active dataset_id."
19
+ columns:
20
+ - name: type
21
+ description: "NodeType — 'user', 'repo', 'artifact'."
22
+ - name: id
23
+ - name: ts
24
+ - name: attrs
25
+ - name: active_edges
26
+ description: "View over edges filtered to the active dataset_id."
27
+ columns:
28
+ - name: type
29
+ description: "EdgeType — 'opened', 'reviewed', etc."
30
+ - name: src_id
31
+ - name: dst_id
32
+ - name: ts
33
+ - name: attrs
34
+ - name: active_contract_accounts
35
+ description: "Adapter contract accounts for the active dataset_id."
36
+ - name: active_contract_repositories
37
+ description: "Adapter contract repositories for the active dataset_id."
38
+ - name: active_contract_artifacts
39
+ description: "Adapter contract artifacts for the active dataset_id."
40
+ - name: active_contract_relations
41
+ description: "Adapter contract relations for the active dataset_id."
42
+ - name: active_contract_adapter_manifest
43
+ description: "Adapter manifest/capabilities for the active dataset_id."
44
+ - name: active_hin_nodes
45
+ description: "Canonical HIN node view from HIN_DBT_DESIGN.md, filtered to the active dataset_id."
46
+ columns:
47
+ - name: node_id
48
+ - name: node_type
49
+ - name: node_subtype
50
+ - name: natural_key
51
+ - name: display_name
52
+ - name: properties
53
+ - name: active_hin_edges
54
+ description: "Canonical HIN edge view from HIN_DBT_DESIGN.md, filtered to the active dataset_id."
55
+ columns:
56
+ - name: edge_id
57
+ - name: source_node_id
58
+ - name: source_node_type
59
+ - name: target_node_id
60
+ - name: target_node_type
61
+ - name: edge_type
62
+ - name: relation_subtype
63
+ - name: properties
@@ -0,0 +1,4 @@
1
+ {{ config(materialized='view') }}
2
+
3
+ SELECT *
4
+ FROM {{ source('hin', 'active_contract_accounts') }}
@@ -0,0 +1,4 @@
1
+ {{ config(materialized='view') }}
2
+
3
+ SELECT *
4
+ FROM {{ source('hin', 'active_contract_adapter_manifest') }}
@@ -0,0 +1,4 @@
1
+ {{ config(materialized='view') }}
2
+
3
+ SELECT *
4
+ FROM {{ source('hin', 'active_contract_artifacts') }}
@@ -0,0 +1,4 @@
1
+ {{ config(materialized='view') }}
2
+
3
+ SELECT *
4
+ FROM {{ source('hin', 'active_contract_relations') }}
@@ -0,0 +1,4 @@
1
+ {{ config(materialized='view') }}
2
+
3
+ SELECT *
4
+ FROM {{ source('hin', 'active_contract_repositories') }}
hinge/dbt/profiles.yml ADDED
@@ -0,0 +1,9 @@
1
+ # The DuckDB path is injected by DbtProjection at run time via the
2
+ # HINGE_STORE_PATH env var — do NOT hard-code a path here.
3
+ hinge:
4
+ target: dev
5
+ outputs:
6
+ dev:
7
+ type: duckdb
8
+ path: "{{ env_var('HINGE_STORE_PATH') }}"
9
+ threads: 4
@@ -0,0 +1,35 @@
1
+ recipe_name,required_capability
2
+ artifact_reference,has_artifact_refs
3
+ co_commit_user_user,has_commits
4
+ co_edit_file_user_user,has_commits
5
+ co_edit_file_user_user,has_file_touches
6
+ co_edit_line_user_user,has_commits
7
+ co_edit_line_user_user,has_line_touches
8
+ dev_interaction,has_pull_requests
9
+ dev_interaction,has_pr_reviews
10
+ dev_interaction,has_issues
11
+ dev_interaction,has_comments
12
+ developer_repo_affiliation,has_pull_requests
13
+ developer_repo_affiliation,has_pr_reviews
14
+ developer_repo_affiliation,has_issues
15
+ developer_repo_affiliation,has_comments
16
+ follow_user_user,has_follows
17
+ fork_repo_repo,has_forks
18
+ issue_co_participation,has_issues
19
+ issue_co_participation,has_comments
20
+ issue_participation,has_issues
21
+ issue_participation,has_comments
22
+ pr_author_reviewer,has_pull_requests
23
+ pr_author_reviewer,has_pr_reviews
24
+ pr_participation,has_pull_requests
25
+ pr_participation,has_pr_reviews
26
+ pr_reviewer_coreview,has_pull_requests
27
+ pr_reviewer_coreview,has_pr_reviews
28
+ repo_shared_contributors,has_pull_requests
29
+ repo_shared_contributors,has_pr_reviews
30
+ repo_shared_contributors,has_issues
31
+ repo_shared_contributors,has_comments
32
+ star_user_repo,has_stars
33
+ user_mention_user,has_mentions
34
+ user_mention_user,has_comments
35
+ watch_user_repo,has_watches
File without changes
@@ -0,0 +1,3 @@
1
+ from hinge.frontends.cli.app import app
2
+
3
+ __all__ = ["app"]
@@ -0,0 +1,189 @@
1
+ """Typer-based CLI entry point.
2
+
3
+ Usage:
4
+
5
+ hinge ingest path/to/events.jsonl --reader numfocus
6
+ hinge export --dataset <id> --projection dev-interaction --format gml -o out.gml
7
+ hinge list datasets
8
+ hinge list projections
9
+ hinge list readers
10
+ hinge list exporters
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ from contextlib import suppress
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from hinge.config.logging_setup import setup_logging
24
+ from hinge.frontends import lib
25
+
26
+ setup_logging()
27
+
28
+ app = typer.Typer(help="hinge — Heterogeneous Information Network tool", no_args_is_help=True)
29
+ list_app = typer.Typer(help="List available resources", no_args_is_help=True)
30
+ app.add_typer(list_app, name="list")
31
+
32
+ console = Console()
33
+
34
+
35
+ @app.command()
36
+ def ingest(
37
+ path: Path = typer.Argument(..., exists=True, readable=True, help="Dataset file."),
38
+ reader: str = typer.Option(..., "--reader", "-r", help="Reader name (e.g. numfocus)."),
39
+ ) -> None:
40
+ """Read a dataset file, extract typed nodes/edges, persist to the DuckDB store."""
41
+ report = lib.ingest(path, reader=reader)
42
+ console.print(
43
+ f"[green]ingested[/green] {report.elements_read:,} elements "
44
+ f"→ {report.nodes_upserted:,} nodes, {report.edges_upserted:,} edges "
45
+ f"({report.violation_count} violations)\n"
46
+ f"[bold]Dataset ID:[/bold] {report.dataset_id}"
47
+ )
48
+
49
+
50
+ @app.command()
51
+ def export(
52
+ dataset: str = typer.Option(..., "--dataset", "-d", help="Dataset ID from a previous ingest."),
53
+ projection: str = typer.Option(..., "--projection", "-p", help="Projection name."),
54
+ format_: str = typer.Option(..., "--format", "-f", help="Exporter name (e.g. gml)."),
55
+ output: Path = typer.Option(..., "--output", "-o", help="Output file."),
56
+ ) -> None:
57
+ """Run a projection over a specific dataset and export the result."""
58
+ output.parent.mkdir(parents=True, exist_ok=True)
59
+ try:
60
+ with output.open("wb") as sink:
61
+ receipt = lib.export(
62
+ projection_name=projection, dataset_id=dataset, fmt=format_, sink=sink
63
+ )
64
+ except subprocess.CalledProcessError as exc:
65
+ _remove_empty_output(output)
66
+ _print_process_error(exc)
67
+ raise typer.Exit(code=1) from None
68
+ console.print(
69
+ f"[green]exported[/green] {receipt.node_count:,} nodes, "
70
+ f"{receipt.edge_count:,} edges → {output} "
71
+ f"(snapshot {receipt.snapshot_id[:12] if receipt.snapshot_id else '—'})"
72
+ )
73
+
74
+
75
+ @app.command("export-sql")
76
+ def export_sql(
77
+ sql: Path = typer.Argument(..., exists=True, readable=True, help="Custom dbt model SQL file."),
78
+ dataset: str = typer.Option(..., "--dataset", "-d", help="Dataset ID from a previous ingest."),
79
+ format_: str = typer.Option(..., "--format", "-f", help="Exporter name (e.g. gml)."),
80
+ output: Path = typer.Option(..., "--output", "-o", help="Output file."),
81
+ name: str | None = typer.Option(None, "--name", help="dbt model name; defaults to SQL stem."),
82
+ ) -> None:
83
+ """Run a local SQL projection that can use the built-in dbt HIN macros."""
84
+ output.parent.mkdir(parents=True, exist_ok=True)
85
+ try:
86
+ with output.open("wb") as sink:
87
+ receipt = lib.export_sql_projection(
88
+ sql_path=sql, dataset_id=dataset, fmt=format_, sink=sink, name=name
89
+ )
90
+ except subprocess.CalledProcessError as exc:
91
+ _remove_empty_output(output)
92
+ _print_process_error(exc)
93
+ raise typer.Exit(code=1) from None
94
+ console.print(
95
+ f"[green]exported[/green] {receipt.node_count:,} nodes, "
96
+ f"{receipt.edge_count:,} edges → {output} "
97
+ f"(snapshot {receipt.snapshot_id[:12] if receipt.snapshot_id else '—'})"
98
+ )
99
+
100
+
101
+ def _print_process_error(exc: subprocess.CalledProcessError) -> None:
102
+ console.print(f"[red]error:[/red] {_process_error_message(exc)}")
103
+
104
+
105
+ def _process_error_message(exc: subprocess.CalledProcessError) -> str:
106
+ text = f"{_process_text(exc.output)}\n{_process_text(exc.stderr)}"
107
+ capability_failure = _extract_capability_failure(text)
108
+ if capability_failure:
109
+ return capability_failure
110
+ return f"dbt failed with exit code {exc.returncode}. Set HINGE_LOG_LEVEL=DEBUG for details."
111
+
112
+
113
+ def _process_text(value: object) -> str:
114
+ if value is None:
115
+ return ""
116
+ if isinstance(value, bytes):
117
+ return value.decode(errors="replace")
118
+ if isinstance(value, str):
119
+ return value
120
+ return str(value)
121
+
122
+
123
+ def _extract_capability_failure(text: str) -> str | None:
124
+ for line in text.splitlines():
125
+ if "Cannot build `" not in line or "Missing capabilities:" not in line:
126
+ continue
127
+ message = line.strip()
128
+ prefix_index = message.find("Cannot build `")
129
+ if prefix_index >= 0:
130
+ message = message[prefix_index:]
131
+ return message.replace(". Missing capabilities:", ".\nMissing capabilities:")
132
+ return None
133
+
134
+
135
+ def _remove_empty_output(path: Path) -> None:
136
+ with suppress(OSError):
137
+ if path.exists() and path.stat().st_size == 0:
138
+ path.unlink()
139
+
140
+
141
+ @list_app.command("datasets")
142
+ def list_datasets() -> None:
143
+ """Show all ingested datasets stored in the DuckDB file."""
144
+ datasets = lib.list_datasets()
145
+ if not datasets:
146
+ console.print("[yellow]No datasets ingested yet.[/yellow]")
147
+ return
148
+ table = Table("dataset_id", "reader", "path", "ingested_at", "nodes", "edges")
149
+ for d in datasets:
150
+ table.add_row(
151
+ d.dataset_id,
152
+ d.reader,
153
+ d.path,
154
+ d.ingested_at.strftime("%Y-%m-%d %H:%M:%S"),
155
+ str(d.node_count) if d.node_count is not None else "—",
156
+ str(d.edge_count) if d.edge_count is not None else "—",
157
+ )
158
+ console.print(table)
159
+
160
+
161
+ @list_app.command("projections")
162
+ def list_projections() -> None:
163
+ """List all registered projection names (pass to --projection on export)."""
164
+ table = Table("name")
165
+ for name in lib.list_projections():
166
+ table.add_row(name)
167
+ console.print(table)
168
+
169
+
170
+ @list_app.command("exporters")
171
+ def list_exporters() -> None:
172
+ """List all registered exporter names (pass to --format on export)."""
173
+ table = Table("name")
174
+ for name in lib.list_exporters():
175
+ table.add_row(name)
176
+ console.print(table)
177
+
178
+
179
+ @list_app.command("readers")
180
+ def list_readers() -> None:
181
+ """List all registered reader names (pass to --reader on ingest)."""
182
+ table = Table("name")
183
+ for name in lib.list_readers():
184
+ table.add_row(name)
185
+ console.print(table)
186
+
187
+
188
+ if __name__ == "__main__":
189
+ app()
hinge/frontends/lib.py ADDED
@@ -0,0 +1,148 @@
1
+ """Public library facade.
2
+
3
+ ``import hinge`` and you get these functions. Each one assembles the pipeline
4
+ through the registry and hands it to the runner — frontends do not import
5
+ concrete stage classes.
6
+
7
+ The three-phase export dance (scope → dbt → fingerprint) is explicit here so
8
+ the DuckDB single-writer lock is released between phases. The store is a
9
+ context manager; the surrounding ``with`` blocks make ownership obvious.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any, BinaryIO, cast
16
+
17
+ from hinge.config.settings import types_yaml_path
18
+ from hinge.kernel import registry, runner
19
+ from hinge.kernel.projection.projection_spec import ProjectionSpec
20
+ from hinge.kernel.protocols.store_stage import DatasetMeta
21
+ from hinge.kernel.runner import IngestReport
22
+ from hinge.kernel.schema.hin_schema import HINSchema
23
+
24
+
25
+ def ingest(path: str | Path, reader: str) -> IngestReport:
26
+ rdr = registry.get_reader(reader, path=path)
27
+ schema = HINSchema.from_yaml(types_yaml_path())
28
+ with registry.get_store() as store:
29
+ return runner.run_ingest(rdr, store, schema)
30
+
31
+
32
+ def export(
33
+ projection_name: str,
34
+ dataset_id: str,
35
+ fmt: str,
36
+ sink: BinaryIO,
37
+ **params: Any,
38
+ ) -> Any:
39
+ schema = HINSchema.from_yaml(types_yaml_path())
40
+ proj_stage = registry.get_projection("dbt")
41
+ spec = registry.get_projection_spec(projection_name)
42
+ exporter = registry.get_exporter(fmt)
43
+
44
+ # Phase 1: writable store creates dataset-scoped views, then releases the
45
+ # file lock so the dbt subprocess can acquire it.
46
+ with registry.get_store() as store:
47
+ view = store.scope_to_dataset(dataset_id)
48
+
49
+ # Phase 2: dbt subprocess materialises the projection.
50
+ handle = runner.run_projection(proj_stage, spec, view, params)
51
+
52
+ # Phase 3: read-only store for fingerprinting.
53
+ with registry.get_store(read_only=True) as store:
54
+ return runner.run_export(
55
+ handle,
56
+ exporter,
57
+ sink,
58
+ store=store,
59
+ projection=proj_stage,
60
+ schema=schema,
61
+ dataset_id=dataset_id,
62
+ )
63
+
64
+
65
+ def export_sql_projection(
66
+ sql_path: str | Path,
67
+ dataset_id: str,
68
+ fmt: str,
69
+ sink: BinaryIO,
70
+ *,
71
+ name: str | None = None,
72
+ **params: Any,
73
+ ) -> Any:
74
+ sql_path = Path(sql_path)
75
+ model_name = name or sql_path.stem
76
+ schema = HINSchema.from_yaml(types_yaml_path())
77
+ proj_stage = registry.get_projection(
78
+ "dbt", custom_model_path=sql_path, custom_model_name=model_name
79
+ )
80
+ spec = ProjectionSpec(
81
+ name=model_name,
82
+ description=f"Custom SQL projection from {sql_path}",
83
+ model_name=model_name,
84
+ output_node_types=[],
85
+ output_edge_types=[],
86
+ )
87
+ exporter = registry.get_exporter(fmt)
88
+
89
+ with registry.get_store() as store:
90
+ view = store.scope_to_dataset(dataset_id)
91
+
92
+ handle = runner.run_projection(proj_stage, spec, view, params)
93
+
94
+ with registry.get_store(read_only=True) as store:
95
+ return runner.run_export(
96
+ handle,
97
+ exporter,
98
+ sink,
99
+ store=store,
100
+ projection=proj_stage,
101
+ schema=schema,
102
+ dataset_id=dataset_id,
103
+ )
104
+
105
+
106
+ def project(name: str, dataset_id: str, **params: Any) -> Any:
107
+ proj_stage = registry.get_projection("dbt")
108
+ spec = registry.get_projection_spec(name)
109
+ with registry.get_store() as store:
110
+ view = store.scope_to_dataset(dataset_id)
111
+ return runner.run_projection(proj_stage, spec, view, params)
112
+
113
+
114
+ def project_sql(
115
+ sql_path: str | Path, dataset_id: str, *, name: str | None = None, **params: Any
116
+ ) -> Any:
117
+ sql_path = Path(sql_path)
118
+ model_name = name or sql_path.stem
119
+ proj_stage = registry.get_projection(
120
+ "dbt", custom_model_path=sql_path, custom_model_name=model_name
121
+ )
122
+ spec = ProjectionSpec(
123
+ name=model_name,
124
+ description=f"Custom SQL projection from {sql_path}",
125
+ model_name=model_name,
126
+ output_node_types=[],
127
+ output_edge_types=[],
128
+ )
129
+ with registry.get_store() as store:
130
+ view = store.scope_to_dataset(dataset_id)
131
+ return runner.run_projection(proj_stage, spec, view, params)
132
+
133
+
134
+ def list_datasets() -> list[DatasetMeta]:
135
+ with registry.get_store(read_only=True) as store:
136
+ return cast(list[DatasetMeta], store.list_datasets())
137
+
138
+
139
+ def list_projections() -> list[str]:
140
+ return registry.list_registered("projection_spec")
141
+
142
+
143
+ def list_exporters() -> list[str]:
144
+ return registry.list_registered("exporter")
145
+
146
+
147
+ def list_readers() -> list[str]:
148
+ return registry.list_registered("reader")
@@ -0,0 +1,7 @@
1
+ """Textual TUI — stub.
2
+
3
+ Plan: a single-screen app that lists registered sources, projections and
4
+ exporters; lets the user pick one of each; runs the pipeline; streams progress.
5
+ The frontend must compose stages via registry + runner only — no direct
6
+ imports from hinge.stages.
7
+ """
@@ -0,0 +1,6 @@
1
+ """FastAPI web frontend — stub.
2
+
3
+ Plan: REST endpoints POST /ingest, POST /export, GET /projections, with an
4
+ SSE channel for progress events. Same composition rule as the CLI:
5
+ registry + runner only.
6
+ """
File without changes
@@ -0,0 +1,9 @@
1
+ from hinge.kernel.projection.projected_graph import ProjectedGraph, ProjectedGraphHandle
2
+ from hinge.kernel.projection.projection_spec import ProjectionParams, ProjectionSpec
3
+
4
+ __all__ = [
5
+ "ProjectedGraph",
6
+ "ProjectedGraphHandle",
7
+ "ProjectionParams",
8
+ "ProjectionSpec",
9
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Protocol
5
+
6
+ from hinge.kernel.schema.typed_edge import TypedEdge
7
+ from hinge.kernel.schema.typed_node import TypedNode
8
+
9
+
10
+ class ProjectedGraphHandle(Protocol):
11
+ """Lazy cursor over a materialised projection.
12
+
13
+ Implementations stream rows from the result table — exporters must not
14
+ assume the full graph fits in memory.
15
+ """
16
+
17
+ def iter_nodes(self) -> Iterator[TypedNode]: ...
18
+ def iter_edges(self) -> Iterator[TypedEdge]: ...
19
+
20
+
21
+ class ProjectedGraph:
22
+ """Concrete, in-memory ProjectedGraphHandle. Useful for tests."""
23
+
24
+ def __init__(self, nodes: list[TypedNode], edges: list[TypedEdge]) -> None:
25
+ self._nodes = nodes
26
+ self._edges = edges
27
+
28
+ def iter_nodes(self) -> Iterator[TypedNode]:
29
+ return iter(self._nodes)
30
+
31
+ def iter_edges(self) -> Iterator[TypedEdge]:
32
+ return iter(self._edges)