grai-build 0.3.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.
- grai/__init__.py +11 -0
- grai/cli/__init__.py +5 -0
- grai/cli/main.py +2546 -0
- grai/core/__init__.py +1 -0
- grai/core/cache/__init__.py +33 -0
- grai/core/cache/build_cache.py +352 -0
- grai/core/compiler/__init__.py +23 -0
- grai/core/compiler/cypher_compiler.py +426 -0
- grai/core/exporter/__init__.py +13 -0
- grai/core/exporter/ir_exporter.py +343 -0
- grai/core/lineage/__init__.py +42 -0
- grai/core/lineage/lineage_tracker.py +685 -0
- grai/core/loader/__init__.py +21 -0
- grai/core/loader/neo4j_loader.py +514 -0
- grai/core/models.py +344 -0
- grai/core/parser/__init__.py +25 -0
- grai/core/parser/yaml_parser.py +375 -0
- grai/core/validator/__init__.py +25 -0
- grai/core/validator/validator.py +475 -0
- grai/core/visualizer/__init__.py +650 -0
- grai/core/visualizer/visualizer.py +15 -0
- grai/templates/__init__.py +1 -0
- grai_build-0.3.0.dist-info/METADATA +374 -0
- grai_build-0.3.0.dist-info/RECORD +28 -0
- grai_build-0.3.0.dist-info/WHEEL +5 -0
- grai_build-0.3.0.dist-info/entry_points.txt +2 -0
- grai_build-0.3.0.dist-info/licenses/LICENSE +21 -0
- grai_build-0.3.0.dist-info/top_level.txt +1 -0
grai/cli/main.py
ADDED
|
@@ -0,0 +1,2546 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CLI application for grai.build.
|
|
3
|
+
|
|
4
|
+
This module provides the Typer-based command-line interface for grai.build,
|
|
5
|
+
offering commands for project initialization, validation, compilation, and execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from grai import __version__
|
|
17
|
+
from grai.core.cache import (
|
|
18
|
+
clear_cache,
|
|
19
|
+
load_cache,
|
|
20
|
+
should_rebuild,
|
|
21
|
+
update_cache,
|
|
22
|
+
)
|
|
23
|
+
from grai.core.compiler import compile_and_write, compile_schema_only
|
|
24
|
+
from grai.core.lineage import (
|
|
25
|
+
build_lineage_graph,
|
|
26
|
+
calculate_impact_analysis,
|
|
27
|
+
get_entity_lineage,
|
|
28
|
+
get_lineage_statistics,
|
|
29
|
+
get_relation_lineage,
|
|
30
|
+
visualize_lineage_graphviz,
|
|
31
|
+
visualize_lineage_mermaid,
|
|
32
|
+
)
|
|
33
|
+
from grai.core.models import Project
|
|
34
|
+
from grai.core.parser import load_project
|
|
35
|
+
from grai.core.validator import validate_project
|
|
36
|
+
from grai.core.visualizer import (
|
|
37
|
+
generate_cytoscape_visualization,
|
|
38
|
+
generate_d3_visualization,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Initialize Typer app
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
name="grai",
|
|
44
|
+
help="Declarative knowledge graph modeling tool inspired by dbt.",
|
|
45
|
+
add_completion=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Rich console for pretty output
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def version_callback(value: bool):
|
|
53
|
+
"""Show version information."""
|
|
54
|
+
if value:
|
|
55
|
+
console.print(f"grai.build version {__version__}", style="bold green")
|
|
56
|
+
raise typer.Exit()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback()
|
|
60
|
+
def main(
|
|
61
|
+
version: Optional[bool] = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--version",
|
|
64
|
+
"-v",
|
|
65
|
+
help="Show version and exit.",
|
|
66
|
+
callback=version_callback,
|
|
67
|
+
is_eager=True,
|
|
68
|
+
),
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
grai.build - Declarative knowledge graph modeling.
|
|
72
|
+
|
|
73
|
+
Define entities and relations in YAML, validate schemas,
|
|
74
|
+
compile to Cypher, and load into Neo4j.
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def init(
|
|
81
|
+
path: Path = typer.Argument(
|
|
82
|
+
Path("."),
|
|
83
|
+
help="Directory to initialize project in (default: current directory).",
|
|
84
|
+
),
|
|
85
|
+
name: Optional[str] = typer.Option(
|
|
86
|
+
None,
|
|
87
|
+
"--name",
|
|
88
|
+
"-n",
|
|
89
|
+
help="Project name (default: directory name).",
|
|
90
|
+
),
|
|
91
|
+
force: bool = typer.Option(
|
|
92
|
+
False,
|
|
93
|
+
"--force",
|
|
94
|
+
"-f",
|
|
95
|
+
help="Overwrite existing files.",
|
|
96
|
+
),
|
|
97
|
+
):
|
|
98
|
+
"""
|
|
99
|
+
Initialize a new grai.build project in the current directory.
|
|
100
|
+
|
|
101
|
+
Creates a starter project with example entities and relations.
|
|
102
|
+
Initializes in the current directory by default (like git init, npm init).
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
grai init # Initialize in current directory
|
|
106
|
+
grai init --name my-graph # Initialize with custom project name
|
|
107
|
+
grai init /path/to/project # Initialize in specific directory
|
|
108
|
+
"""
|
|
109
|
+
project_dir = path.resolve()
|
|
110
|
+
|
|
111
|
+
# Infer project name from directory if not provided
|
|
112
|
+
if name is None:
|
|
113
|
+
name = project_dir.name
|
|
114
|
+
if name == ".":
|
|
115
|
+
name = "my-knowledge-graph"
|
|
116
|
+
|
|
117
|
+
console.print(f"\n[bold cyan]🚀 Initializing grai.build project: {name}[/bold cyan]\n")
|
|
118
|
+
|
|
119
|
+
# Check if grai.yml already exists (not the directory itself)
|
|
120
|
+
grai_yml_path = project_dir / "grai.yml"
|
|
121
|
+
if grai_yml_path.exists() and not force:
|
|
122
|
+
console.print("[red]✗ Project already initialized (grai.yml exists)[/red]")
|
|
123
|
+
console.print("[yellow]Use --force to overwrite existing files[/yellow]")
|
|
124
|
+
raise typer.Exit(code=1)
|
|
125
|
+
|
|
126
|
+
# Create directory structure
|
|
127
|
+
try:
|
|
128
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
(project_dir / "entities").mkdir(exist_ok=True)
|
|
130
|
+
(project_dir / "relations").mkdir(exist_ok=True)
|
|
131
|
+
(project_dir / "target" / "neo4j").mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
# Create grai.yml
|
|
134
|
+
grai_yml = f"""name: {name}
|
|
135
|
+
version: 1.0.0
|
|
136
|
+
description: A knowledge graph project built with grai.build
|
|
137
|
+
|
|
138
|
+
# Optional: Specify custom directories
|
|
139
|
+
# entity_dir: entities
|
|
140
|
+
# relation_dir: relations
|
|
141
|
+
# target_dir: target
|
|
142
|
+
|
|
143
|
+
# Optional: Neo4j connection settings
|
|
144
|
+
# neo4j:
|
|
145
|
+
# uri: bolt://localhost:7687
|
|
146
|
+
# user: neo4j
|
|
147
|
+
# password: password
|
|
148
|
+
"""
|
|
149
|
+
(project_dir / "grai.yml").write_text(grai_yml)
|
|
150
|
+
|
|
151
|
+
# Create example entity (using enhanced source configuration)
|
|
152
|
+
customer_yml = """entity: customer
|
|
153
|
+
# Enhanced source configuration with metadata
|
|
154
|
+
source:
|
|
155
|
+
name: customers
|
|
156
|
+
type: table
|
|
157
|
+
db_schema: analytics
|
|
158
|
+
database: warehouse
|
|
159
|
+
connection: prod_db
|
|
160
|
+
metadata:
|
|
161
|
+
owner: data-team
|
|
162
|
+
refresh_schedule: daily
|
|
163
|
+
keys:
|
|
164
|
+
- customer_id
|
|
165
|
+
properties:
|
|
166
|
+
- name: customer_id
|
|
167
|
+
type: string
|
|
168
|
+
required: true
|
|
169
|
+
description: Unique customer identifier
|
|
170
|
+
- name: name
|
|
171
|
+
type: string
|
|
172
|
+
description: Customer name
|
|
173
|
+
- name: email
|
|
174
|
+
type: string
|
|
175
|
+
description: Customer email address
|
|
176
|
+
- name: created_at
|
|
177
|
+
type: datetime
|
|
178
|
+
description: Account creation timestamp
|
|
179
|
+
description: Customer entity from analytics warehouse
|
|
180
|
+
"""
|
|
181
|
+
(project_dir / "entities" / "customer.yml").write_text(customer_yml)
|
|
182
|
+
|
|
183
|
+
# Create example entity (simple source format for comparison)
|
|
184
|
+
product_yml = """entity: product
|
|
185
|
+
# Simple source format (backward compatible - type is auto-inferred as 'table')
|
|
186
|
+
source: analytics.products
|
|
187
|
+
keys:
|
|
188
|
+
- product_id
|
|
189
|
+
properties:
|
|
190
|
+
- name: product_id
|
|
191
|
+
type: string
|
|
192
|
+
required: true
|
|
193
|
+
description: Unique product identifier
|
|
194
|
+
- name: name
|
|
195
|
+
type: string
|
|
196
|
+
description: Product name
|
|
197
|
+
- name: category
|
|
198
|
+
type: string
|
|
199
|
+
description: Product category
|
|
200
|
+
- name: price
|
|
201
|
+
type: float
|
|
202
|
+
description: Product price
|
|
203
|
+
description: Product entity using simple source format
|
|
204
|
+
"""
|
|
205
|
+
(project_dir / "entities" / "product.yml").write_text(product_yml)
|
|
206
|
+
|
|
207
|
+
# Create example relation (enhanced source with CSV type)
|
|
208
|
+
purchased_yml = """relation: PURCHASED
|
|
209
|
+
from: customer
|
|
210
|
+
to: product
|
|
211
|
+
# Enhanced source showing CSV file configuration
|
|
212
|
+
source:
|
|
213
|
+
name: orders.csv
|
|
214
|
+
type: csv
|
|
215
|
+
format: utf-8
|
|
216
|
+
metadata:
|
|
217
|
+
location: ./data/
|
|
218
|
+
delimiter: ","
|
|
219
|
+
mappings:
|
|
220
|
+
from_key: customer_id
|
|
221
|
+
to_key: product_id
|
|
222
|
+
properties:
|
|
223
|
+
- name: order_id
|
|
224
|
+
type: string
|
|
225
|
+
required: true
|
|
226
|
+
description: Unique order identifier
|
|
227
|
+
- name: order_date
|
|
228
|
+
type: date
|
|
229
|
+
description: Date of purchase
|
|
230
|
+
- name: quantity
|
|
231
|
+
type: integer
|
|
232
|
+
description: Quantity purchased
|
|
233
|
+
- name: total_amount
|
|
234
|
+
type: float
|
|
235
|
+
description: Total order amount
|
|
236
|
+
description: Purchase relationship with CSV source configuration
|
|
237
|
+
"""
|
|
238
|
+
(project_dir / "relations" / "purchased.yml").write_text(purchased_yml)
|
|
239
|
+
|
|
240
|
+
# Create data directory for CSV files
|
|
241
|
+
(project_dir / "data").mkdir(exist_ok=True)
|
|
242
|
+
|
|
243
|
+
# Create sample CSV for customers
|
|
244
|
+
customer_csv = """customer_id,name,email,created_at
|
|
245
|
+
C001,Alice Johnson,alice@example.com,2024-01-15T10:30:00Z
|
|
246
|
+
C002,Bob Smith,bob@example.com,2024-01-20T14:15:00Z
|
|
247
|
+
C003,Carol Williams,carol@example.com,2024-02-05T09:45:00Z
|
|
248
|
+
C004,David Brown,david@example.com,2024-02-10T16:20:00Z
|
|
249
|
+
C005,Emma Davis,emma@example.com,2024-02-15T11:00:00Z
|
|
250
|
+
"""
|
|
251
|
+
(project_dir / "data" / "customers.csv").write_text(customer_csv)
|
|
252
|
+
|
|
253
|
+
# Create sample CSV for products
|
|
254
|
+
product_csv = """product_id,name,category,price
|
|
255
|
+
P001,Laptop Pro 15,Electronics,1299.99
|
|
256
|
+
P002,Wireless Mouse,Accessories,29.99
|
|
257
|
+
P003,USB-C Hub,Accessories,49.99
|
|
258
|
+
P004,Monitor 27",Electronics,399.99
|
|
259
|
+
P005,Keyboard Mechanical,Accessories,129.99
|
|
260
|
+
P006,Webcam HD,Electronics,79.99
|
|
261
|
+
"""
|
|
262
|
+
(project_dir / "data" / "products.csv").write_text(product_csv)
|
|
263
|
+
|
|
264
|
+
# Create sample CSV for purchases
|
|
265
|
+
purchased_csv = """customer_id,product_id,order_id,order_date,quantity,total_amount
|
|
266
|
+
C001,P001,O001,2024-03-01,1,1299.99
|
|
267
|
+
C001,P002,O002,2024-03-01,2,59.98
|
|
268
|
+
C002,P003,O003,2024-03-05,1,49.99
|
|
269
|
+
C002,P005,O004,2024-03-05,1,129.99
|
|
270
|
+
C003,P001,O005,2024-03-10,1,1299.99
|
|
271
|
+
C003,P004,O006,2024-03-10,1,399.99
|
|
272
|
+
C004,P002,O007,2024-03-15,1,29.99
|
|
273
|
+
C004,P006,O008,2024-03-15,1,79.99
|
|
274
|
+
C005,P005,O009,2024-03-20,1,129.99
|
|
275
|
+
C005,P003,O010,2024-03-20,2,99.98
|
|
276
|
+
"""
|
|
277
|
+
(project_dir / "data" / "purchased.csv").write_text(purchased_csv)
|
|
278
|
+
|
|
279
|
+
# Create Cypher script for loading data
|
|
280
|
+
# Get absolute path to data directory for LOAD CSV
|
|
281
|
+
# Convert to file:// URL properly (file:// + absolute path)
|
|
282
|
+
data_dir_abs = (project_dir / "data").resolve()
|
|
283
|
+
file_url_prefix = f"file://{data_dir_abs}"
|
|
284
|
+
|
|
285
|
+
load_cypher = f"""// ============================================
|
|
286
|
+
// Load Sample Data from CSV Files
|
|
287
|
+
// ============================================
|
|
288
|
+
//
|
|
289
|
+
// This script loads sample data into Neo4j using LOAD CSV.
|
|
290
|
+
// Make sure you've already created the schema with: grai run
|
|
291
|
+
//
|
|
292
|
+
// To use this script:
|
|
293
|
+
// 1. Open Neo4j Browser (http://localhost:7474)
|
|
294
|
+
// 2. Copy and paste this entire script
|
|
295
|
+
// 3. Run it
|
|
296
|
+
//
|
|
297
|
+
// Or use cypher-shell:
|
|
298
|
+
// cat load_data.cypher | cypher-shell -u neo4j -p yourpassword
|
|
299
|
+
//
|
|
300
|
+
// ============================================
|
|
301
|
+
|
|
302
|
+
// Load Customers
|
|
303
|
+
LOAD CSV WITH HEADERS FROM '{file_url_prefix}/customers.csv' AS row
|
|
304
|
+
MERGE (c:customer {{customer_id: row.customer_id}})
|
|
305
|
+
SET c.name = row.name,
|
|
306
|
+
c.email = row.email,
|
|
307
|
+
c.created_at = datetime(row.created_at);
|
|
308
|
+
|
|
309
|
+
// Load Products
|
|
310
|
+
LOAD CSV WITH HEADERS FROM '{file_url_prefix}/products.csv' AS row
|
|
311
|
+
MERGE (p:product {{product_id: row.product_id}})
|
|
312
|
+
SET p.name = row.name,
|
|
313
|
+
p.category = row.category,
|
|
314
|
+
p.price = toFloat(row.price);
|
|
315
|
+
|
|
316
|
+
// Load Purchases (relationships)
|
|
317
|
+
LOAD CSV WITH HEADERS FROM '{file_url_prefix}/purchased.csv' AS row
|
|
318
|
+
MATCH (c:customer {{customer_id: row.customer_id}})
|
|
319
|
+
MATCH (p:product {{product_id: row.product_id}})
|
|
320
|
+
MERGE (c)-[r:PURCHASED]->(p)
|
|
321
|
+
SET r.order_id = row.order_id,
|
|
322
|
+
r.order_date = date(row.order_date),
|
|
323
|
+
r.quantity = toInteger(row.quantity),
|
|
324
|
+
r.total_amount = toFloat(row.total_amount);
|
|
325
|
+
|
|
326
|
+
// ============================================
|
|
327
|
+
// Verify the data was loaded
|
|
328
|
+
// ============================================
|
|
329
|
+
|
|
330
|
+
// Count nodes
|
|
331
|
+
MATCH (n)
|
|
332
|
+
RETURN labels(n) AS type, count(n) AS count
|
|
333
|
+
ORDER BY type;
|
|
334
|
+
|
|
335
|
+
// Count relationships
|
|
336
|
+
MATCH ()-[r]->()
|
|
337
|
+
RETURN type(r) AS relationship, count(r) AS count;
|
|
338
|
+
|
|
339
|
+
// Show sample data
|
|
340
|
+
MATCH (c:customer)-[p:PURCHASED]->(prod:product)
|
|
341
|
+
RETURN c.name, prod.name, p.order_date, p.total_amount
|
|
342
|
+
ORDER BY p.order_date
|
|
343
|
+
LIMIT 5;
|
|
344
|
+
"""
|
|
345
|
+
(project_dir / "load_data.cypher").write_text(load_cypher)
|
|
346
|
+
|
|
347
|
+
# Create README
|
|
348
|
+
readme = f"""# {name}
|
|
349
|
+
|
|
350
|
+
A knowledge graph project built with [grai.build](https://github.com/grai-build/grai.build).
|
|
351
|
+
|
|
352
|
+
## Project Structure
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
{name}/
|
|
356
|
+
├── grai.yml # Project configuration
|
|
357
|
+
├── entities/ # Entity definitions
|
|
358
|
+
│ ├── customer.yml
|
|
359
|
+
│ └── product.yml
|
|
360
|
+
├── relations/ # Relation definitions
|
|
361
|
+
│ └── purchased.yml
|
|
362
|
+
├── data/ # Sample CSV data
|
|
363
|
+
│ ├── customers.csv
|
|
364
|
+
│ ├── products.csv
|
|
365
|
+
│ └── purchased.csv
|
|
366
|
+
├── load_data.py # Script to load CSV data
|
|
367
|
+
└── target/ # Compiled output
|
|
368
|
+
└── neo4j/
|
|
369
|
+
└── compiled.cypher
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Getting Started
|
|
373
|
+
|
|
374
|
+
### 1. Validate your project
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
grai validate
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### 2. Create the schema in Neo4j
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
grai run --uri bolt://localhost:7687 --user neo4j --password password
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
This creates constraints and indexes but no data yet.
|
|
387
|
+
|
|
388
|
+
### 3. Load sample data from CSV files
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
# Edit connection details in load_data.py first!
|
|
392
|
+
python load_data.py
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
This loads:
|
|
396
|
+
- 5 sample customers
|
|
397
|
+
- 6 sample products
|
|
398
|
+
- 10 sample purchase orders
|
|
399
|
+
|
|
400
|
+
### 4. Explore your graph
|
|
401
|
+
|
|
402
|
+
Open Neo4j Browser at http://localhost:7474 and run:
|
|
403
|
+
|
|
404
|
+
```cypher
|
|
405
|
+
// View the entire graph
|
|
406
|
+
MATCH (n)-[r]->(m)
|
|
407
|
+
RETURN n, r, m
|
|
408
|
+
LIMIT 50
|
|
409
|
+
|
|
410
|
+
// Count nodes
|
|
411
|
+
MATCH (n)
|
|
412
|
+
RETURN labels(n) AS type, count(n) AS count
|
|
413
|
+
|
|
414
|
+
// Find high-value customers
|
|
415
|
+
MATCH (c:customer)-[p:PURCHASED]->()
|
|
416
|
+
WITH c, sum(p.total_amount) AS total_spent
|
|
417
|
+
WHERE total_spent > 1000
|
|
418
|
+
RETURN c.name, c.email, total_spent
|
|
419
|
+
ORDER BY total_spent DESC
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Next Steps
|
|
423
|
+
|
|
424
|
+
1. Start your Neo4j database
|
|
425
|
+
2. Run `grai run` to create the schema (constraints & indexes)
|
|
426
|
+
3. Load sample data:
|
|
427
|
+
- Open Neo4j Browser (http://localhost:7474)
|
|
428
|
+
- Copy/paste the contents of `load_data.cypher` and run it
|
|
429
|
+
4. Edit entity definitions in `entities/` as needed
|
|
430
|
+
5. Edit relation definitions in `relations/` as needed
|
|
431
|
+
6. Modify CSV files in `data/` with your own data
|
|
432
|
+
7. Run `grai validate` to check for errors
|
|
433
|
+
8. Run `grai build` to see the compiled Cypher
|
|
434
|
+
|
|
435
|
+
## Source Configuration Formats
|
|
436
|
+
|
|
437
|
+
This project demonstrates both source configuration formats:
|
|
438
|
+
|
|
439
|
+
### Enhanced Format (customer.yml)
|
|
440
|
+
```yaml
|
|
441
|
+
source:
|
|
442
|
+
name: customers
|
|
443
|
+
type: table
|
|
444
|
+
db_schema: analytics
|
|
445
|
+
database: warehouse
|
|
446
|
+
connection: prod_db
|
|
447
|
+
metadata:
|
|
448
|
+
owner: data-team
|
|
449
|
+
refresh_schedule: daily
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Benefits:
|
|
453
|
+
- Explicit source type (table, csv, api, stream, etc.)
|
|
454
|
+
- Additional metadata for documentation
|
|
455
|
+
- Support for multiple connections
|
|
456
|
+
- Better integration with data catalogs
|
|
457
|
+
|
|
458
|
+
### Simple Format (product.yml)
|
|
459
|
+
```yaml
|
|
460
|
+
source: analytics.products
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Benefits:
|
|
464
|
+
- Backward compatible with existing projects
|
|
465
|
+
- Concise for simple use cases
|
|
466
|
+
- Auto-infers type from format (e.g., `schema.table` → type: table)
|
|
467
|
+
|
|
468
|
+
Both formats work identically - use whichever fits your needs!
|
|
469
|
+
|
|
470
|
+
See the project's `docs/` folder for more examples.
|
|
471
|
+
|
|
472
|
+
## Learn More
|
|
473
|
+
|
|
474
|
+
- [grai.build Documentation](https://github.com/grai-build/grai.build)
|
|
475
|
+
- [Neo4j Documentation](https://neo4j.com/docs/)
|
|
476
|
+
"""
|
|
477
|
+
(project_dir / "README.md").write_text(readme)
|
|
478
|
+
|
|
479
|
+
console.print("[green]✓[/green] Created project structure")
|
|
480
|
+
console.print("[green]✓[/green] Created [cyan]grai.yml[/cyan]")
|
|
481
|
+
console.print("[green]✓[/green] Created [cyan]entities/customer.yml[/cyan]")
|
|
482
|
+
console.print("[green]✓[/green] Created [cyan]entities/product.yml[/cyan]")
|
|
483
|
+
console.print("[green]✓[/green] Created [cyan]relations/purchased.yml[/cyan]")
|
|
484
|
+
console.print(
|
|
485
|
+
"[green]✓[/green] Created [cyan]data/customers.csv[/cyan] (5 sample customers)"
|
|
486
|
+
)
|
|
487
|
+
console.print("[green]✓[/green] Created [cyan]data/products.csv[/cyan] (6 sample products)")
|
|
488
|
+
console.print("[green]✓[/green] Created [cyan]data/purchased.csv[/cyan] (10 sample orders)")
|
|
489
|
+
console.print(
|
|
490
|
+
"[green]✓[/green] Created [cyan]load_data.cypher[/cyan] (data loading script)"
|
|
491
|
+
)
|
|
492
|
+
console.print("[green]✓[/green] Created [cyan]README.md[/cyan]")
|
|
493
|
+
|
|
494
|
+
console.print(f"\n[bold green]✓ Successfully initialized project: {name}[/bold green]\n")
|
|
495
|
+
|
|
496
|
+
# Show next steps
|
|
497
|
+
next_steps = "[bold]Next Steps:[/bold]\n\n"
|
|
498
|
+
if project_dir != Path(".").resolve():
|
|
499
|
+
next_steps += f"1. cd {project_dir}\n"
|
|
500
|
+
next_steps += "2. grai validate # Check your definitions\n"
|
|
501
|
+
next_steps += "3. grai build # Compile to Cypher\n"
|
|
502
|
+
next_steps += "4. grai run # Create schema in Neo4j\n"
|
|
503
|
+
next_steps += "5. Copy/paste load_data.cypher in Neo4j Browser to load data"
|
|
504
|
+
else:
|
|
505
|
+
next_steps += "1. grai validate # Check your definitions\n"
|
|
506
|
+
next_steps += "2. grai build # Compile to Cypher\n"
|
|
507
|
+
next_steps += "3. grai run # Create schema in Neo4j\n"
|
|
508
|
+
next_steps += "4. Copy/paste load_data.cypher in Neo4j Browser to load data"
|
|
509
|
+
|
|
510
|
+
panel = Panel(
|
|
511
|
+
next_steps,
|
|
512
|
+
title="[bold cyan]Get Started[/bold cyan]",
|
|
513
|
+
border_style="cyan",
|
|
514
|
+
)
|
|
515
|
+
console.print(panel)
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
console.print(f"[red]✗ Error initializing project: {e}[/red]")
|
|
519
|
+
raise typer.Exit(code=1)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@app.command()
|
|
523
|
+
def validate(
|
|
524
|
+
project_dir: Path = typer.Argument(
|
|
525
|
+
Path("."),
|
|
526
|
+
help="Path to grai.build project directory.",
|
|
527
|
+
),
|
|
528
|
+
strict: bool = typer.Option(
|
|
529
|
+
False,
|
|
530
|
+
"--strict",
|
|
531
|
+
"-s",
|
|
532
|
+
help="Treat warnings as errors.",
|
|
533
|
+
),
|
|
534
|
+
verbose: bool = typer.Option(
|
|
535
|
+
False,
|
|
536
|
+
"--verbose",
|
|
537
|
+
"-v",
|
|
538
|
+
help="Show detailed validation output.",
|
|
539
|
+
),
|
|
540
|
+
):
|
|
541
|
+
"""
|
|
542
|
+
Validate entity and relation definitions.
|
|
543
|
+
|
|
544
|
+
Checks for:
|
|
545
|
+
- Missing entity references
|
|
546
|
+
- Invalid key mappings
|
|
547
|
+
- Duplicate property names
|
|
548
|
+
- Circular dependencies
|
|
549
|
+
"""
|
|
550
|
+
console.print("\n[bold cyan]🔍 Validating project...[/bold cyan]\n")
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
# Load project
|
|
554
|
+
project = load_project(project_dir)
|
|
555
|
+
console.print(
|
|
556
|
+
f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan] (v{project.version})"
|
|
557
|
+
)
|
|
558
|
+
console.print(f" - {len(project.entities)} entities")
|
|
559
|
+
console.print(f" - {len(project.relations)} relations\n")
|
|
560
|
+
|
|
561
|
+
# Validate project
|
|
562
|
+
result = validate_project(project, strict=strict)
|
|
563
|
+
|
|
564
|
+
# Show results
|
|
565
|
+
if result.valid:
|
|
566
|
+
console.print("[bold green]✓ Validation passed![/bold green]\n")
|
|
567
|
+
|
|
568
|
+
if verbose and result.warnings:
|
|
569
|
+
console.print("[yellow]Warnings:[/yellow]")
|
|
570
|
+
for warning in result.warnings:
|
|
571
|
+
console.print(f" [yellow]⚠[/yellow] {warning}")
|
|
572
|
+
console.print()
|
|
573
|
+
|
|
574
|
+
return
|
|
575
|
+
else:
|
|
576
|
+
console.print("[bold red]✗ Validation failed![/bold red]\n")
|
|
577
|
+
|
|
578
|
+
if result.errors:
|
|
579
|
+
console.print("[red]Errors:[/red]")
|
|
580
|
+
for error in result.errors:
|
|
581
|
+
console.print(f" [red]✗[/red] {error}")
|
|
582
|
+
console.print()
|
|
583
|
+
|
|
584
|
+
if result.warnings:
|
|
585
|
+
console.print("[yellow]Warnings:[/yellow]")
|
|
586
|
+
for warning in result.warnings:
|
|
587
|
+
console.print(f" [yellow]⚠[/yellow] {warning}")
|
|
588
|
+
console.print()
|
|
589
|
+
|
|
590
|
+
raise typer.Exit(code=1)
|
|
591
|
+
|
|
592
|
+
except FileNotFoundError as e:
|
|
593
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
594
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
595
|
+
raise typer.Exit(code=1)
|
|
596
|
+
except Exception as e:
|
|
597
|
+
console.print(f"[red]✗ Error during validation: {e}[/red]")
|
|
598
|
+
raise typer.Exit(code=1)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@app.command()
|
|
602
|
+
def build(
|
|
603
|
+
project_dir: Path = typer.Argument(
|
|
604
|
+
Path("."),
|
|
605
|
+
help="Path to grai.build project directory.",
|
|
606
|
+
),
|
|
607
|
+
output_dir: Optional[Path] = typer.Option(
|
|
608
|
+
None,
|
|
609
|
+
"--output",
|
|
610
|
+
"-o",
|
|
611
|
+
help="Output directory for compiled Cypher (default: target/neo4j).",
|
|
612
|
+
),
|
|
613
|
+
filename: str = typer.Option(
|
|
614
|
+
"compiled.cypher",
|
|
615
|
+
"--filename",
|
|
616
|
+
"-f",
|
|
617
|
+
help="Output filename.",
|
|
618
|
+
),
|
|
619
|
+
schema_only: bool = typer.Option(
|
|
620
|
+
True,
|
|
621
|
+
"--schema-only/--with-data",
|
|
622
|
+
help="Generate only schema (constraints and indexes) without data loading statements.",
|
|
623
|
+
),
|
|
624
|
+
skip_validation: bool = typer.Option(
|
|
625
|
+
False,
|
|
626
|
+
"--skip-validation",
|
|
627
|
+
help="Skip validation before compiling.",
|
|
628
|
+
),
|
|
629
|
+
verbose: bool = typer.Option(
|
|
630
|
+
False,
|
|
631
|
+
"--verbose",
|
|
632
|
+
"-v",
|
|
633
|
+
help="Show detailed build output.",
|
|
634
|
+
),
|
|
635
|
+
full: bool = typer.Option(
|
|
636
|
+
False,
|
|
637
|
+
"--full",
|
|
638
|
+
help="Force full rebuild, ignoring cache.",
|
|
639
|
+
),
|
|
640
|
+
no_cache: bool = typer.Option(
|
|
641
|
+
False,
|
|
642
|
+
"--no-cache",
|
|
643
|
+
help="Don't update cache after build.",
|
|
644
|
+
),
|
|
645
|
+
):
|
|
646
|
+
"""
|
|
647
|
+
Build the project by compiling to Cypher.
|
|
648
|
+
|
|
649
|
+
By default, only generates schema (constraints and indexes).
|
|
650
|
+
Use --with-data to include data loading statements (requires LOAD CSV context).
|
|
651
|
+
|
|
652
|
+
Validates the project (unless --skip-validation) and generates
|
|
653
|
+
Neo4j Cypher statements in the target directory.
|
|
654
|
+
|
|
655
|
+
Supports incremental builds by tracking file changes.
|
|
656
|
+
"""
|
|
657
|
+
console.print("\n[bold cyan]🔨 Building project...[/bold cyan]\n")
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
# Check for incremental build
|
|
661
|
+
if not full:
|
|
662
|
+
needs_rebuild, changes = should_rebuild(project_dir)
|
|
663
|
+
|
|
664
|
+
if not needs_rebuild:
|
|
665
|
+
console.print("[green]✓[/green] No changes detected, build is up to date")
|
|
666
|
+
console.print("[dim]Use --full to force a complete rebuild[/dim]")
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
if verbose:
|
|
670
|
+
total_changes = sum(len(files) for files in changes.values())
|
|
671
|
+
console.print(f"[cyan]→[/cyan] Detected {total_changes} file change(s)")
|
|
672
|
+
if changes["added"]:
|
|
673
|
+
console.print(f" [green]+[/green] Added: {len(changes['added'])} file(s)")
|
|
674
|
+
if changes["modified"]:
|
|
675
|
+
console.print(
|
|
676
|
+
f" [yellow]~[/yellow] Modified: {len(changes['modified'])} file(s)"
|
|
677
|
+
)
|
|
678
|
+
if changes["deleted"]:
|
|
679
|
+
console.print(f" [red]-[/red] Deleted: {len(changes['deleted'])} file(s)")
|
|
680
|
+
console.print()
|
|
681
|
+
|
|
682
|
+
# Load project
|
|
683
|
+
project = load_project(project_dir)
|
|
684
|
+
console.print(
|
|
685
|
+
f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan] (v{project.version})"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if verbose:
|
|
689
|
+
console.print(f" - {len(project.entities)} entities")
|
|
690
|
+
console.print(f" - {len(project.relations)} relations")
|
|
691
|
+
|
|
692
|
+
# Validate unless skipped
|
|
693
|
+
if not skip_validation:
|
|
694
|
+
console.print("[cyan]→[/cyan] Validating...")
|
|
695
|
+
result = validate_project(project)
|
|
696
|
+
|
|
697
|
+
if not result.valid:
|
|
698
|
+
console.print("[bold red]✗ Validation failed![/bold red]\n")
|
|
699
|
+
|
|
700
|
+
for error in result.errors:
|
|
701
|
+
console.print(f" [red]✗[/red] {error}")
|
|
702
|
+
|
|
703
|
+
console.print("\n[yellow]Fix validation errors before building[/yellow]")
|
|
704
|
+
console.print("[yellow]Or use --skip-validation to bypass[/yellow]")
|
|
705
|
+
raise typer.Exit(code=1)
|
|
706
|
+
|
|
707
|
+
console.print("[green]✓[/green] Validation passed")
|
|
708
|
+
|
|
709
|
+
if result.warnings and verbose:
|
|
710
|
+
for warning in result.warnings:
|
|
711
|
+
console.print(f" [yellow]⚠[/yellow] {warning}")
|
|
712
|
+
|
|
713
|
+
# Compile
|
|
714
|
+
console.print("[cyan]→[/cyan] Compiling to Cypher...")
|
|
715
|
+
|
|
716
|
+
# Determine output directory
|
|
717
|
+
if output_dir is None:
|
|
718
|
+
output_dir = project_dir / "target" / "neo4j"
|
|
719
|
+
|
|
720
|
+
# Compile
|
|
721
|
+
if schema_only:
|
|
722
|
+
cypher = compile_schema_only(project)
|
|
723
|
+
# Write manually for schema-only
|
|
724
|
+
output_path = output_dir / filename
|
|
725
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
726
|
+
output_path.write_text(cypher)
|
|
727
|
+
else:
|
|
728
|
+
output_path = compile_and_write(project, output_dir=output_dir, filename=filename)
|
|
729
|
+
|
|
730
|
+
console.print("[green]✓[/green] Compiled successfully")
|
|
731
|
+
console.print(f"[green]✓[/green] Wrote output to: [cyan]{output_path}[/cyan]")
|
|
732
|
+
|
|
733
|
+
# Update cache
|
|
734
|
+
if not no_cache:
|
|
735
|
+
console.print("[cyan]→[/cyan] Updating build cache...")
|
|
736
|
+
update_cache(project_dir, project.name, project.version)
|
|
737
|
+
console.print("[green]✓[/green] Cache updated")
|
|
738
|
+
|
|
739
|
+
# Show summary
|
|
740
|
+
console.print("\n[bold green]✓ Build complete![/bold green]\n")
|
|
741
|
+
|
|
742
|
+
if verbose:
|
|
743
|
+
# Count constraints and statements
|
|
744
|
+
cypher_content = output_path.read_text()
|
|
745
|
+
constraint_count = cypher_content.count("CREATE CONSTRAINT")
|
|
746
|
+
index_count = cypher_content.count("CREATE INDEX")
|
|
747
|
+
merge_count = cypher_content.count("MERGE")
|
|
748
|
+
|
|
749
|
+
table = Table(title="Build Summary")
|
|
750
|
+
table.add_column("Metric", style="cyan")
|
|
751
|
+
table.add_column("Count", style="green")
|
|
752
|
+
|
|
753
|
+
table.add_row("Entities", str(len(project.entities)))
|
|
754
|
+
table.add_row("Relations", str(len(project.relations)))
|
|
755
|
+
table.add_row("Constraints", str(constraint_count))
|
|
756
|
+
table.add_row("Indexes", str(index_count))
|
|
757
|
+
table.add_row("Statements", str(merge_count))
|
|
758
|
+
|
|
759
|
+
console.print(table)
|
|
760
|
+
console.print()
|
|
761
|
+
|
|
762
|
+
except FileNotFoundError as e:
|
|
763
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
764
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
765
|
+
raise typer.Exit(code=1)
|
|
766
|
+
except Exception as e:
|
|
767
|
+
console.print(f"[red]✗ Error during build: {e}[/red]")
|
|
768
|
+
raise typer.Exit(code=1)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _load_csv_data(driver, project_dir: Path, database: str, verbose: bool = False) -> bool:
|
|
772
|
+
"""
|
|
773
|
+
Load CSV data from data/ directory if it exists.
|
|
774
|
+
|
|
775
|
+
Reads CSV files and executes parameterized Cypher queries.
|
|
776
|
+
Returns True if data was loaded successfully, False otherwise.
|
|
777
|
+
"""
|
|
778
|
+
import csv
|
|
779
|
+
|
|
780
|
+
from grai.core.loader import execute_cypher
|
|
781
|
+
|
|
782
|
+
data_dir = project_dir / "data"
|
|
783
|
+
|
|
784
|
+
if not data_dir.exists():
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
total_records = 0
|
|
789
|
+
|
|
790
|
+
# Load customers
|
|
791
|
+
customers_csv = data_dir / "customers.csv"
|
|
792
|
+
if customers_csv.exists():
|
|
793
|
+
with open(customers_csv, "r") as f:
|
|
794
|
+
reader = csv.DictReader(f)
|
|
795
|
+
for row in reader:
|
|
796
|
+
cypher = """
|
|
797
|
+
MERGE (c:customer {customer_id: $customer_id})
|
|
798
|
+
SET c.name = $name,
|
|
799
|
+
c.email = $email,
|
|
800
|
+
c.created_at = datetime($created_at)
|
|
801
|
+
"""
|
|
802
|
+
result = execute_cypher(driver, cypher, parameters=row, database=database)
|
|
803
|
+
if result.success:
|
|
804
|
+
total_records += result.records_affected
|
|
805
|
+
|
|
806
|
+
# Load products
|
|
807
|
+
products_csv = data_dir / "products.csv"
|
|
808
|
+
if products_csv.exists():
|
|
809
|
+
with open(products_csv, "r") as f:
|
|
810
|
+
reader = csv.DictReader(f)
|
|
811
|
+
for row in reader:
|
|
812
|
+
cypher = """
|
|
813
|
+
MERGE (p:product {product_id: $product_id})
|
|
814
|
+
SET p.name = $name,
|
|
815
|
+
p.category = $category,
|
|
816
|
+
p.price = toFloat($price)
|
|
817
|
+
"""
|
|
818
|
+
result = execute_cypher(driver, cypher, parameters=row, database=database)
|
|
819
|
+
if result.success:
|
|
820
|
+
total_records += result.records_affected
|
|
821
|
+
|
|
822
|
+
# Load purchases
|
|
823
|
+
purchased_csv = data_dir / "purchased.csv"
|
|
824
|
+
if purchased_csv.exists():
|
|
825
|
+
with open(purchased_csv, "r") as f:
|
|
826
|
+
reader = csv.DictReader(f)
|
|
827
|
+
for row in reader:
|
|
828
|
+
cypher = """
|
|
829
|
+
MATCH (c:customer {customer_id: $customer_id})
|
|
830
|
+
MATCH (p:product {product_id: $product_id})
|
|
831
|
+
MERGE (c)-[r:PURCHASED]->(p)
|
|
832
|
+
SET r.order_id = $order_id,
|
|
833
|
+
r.order_date = date($order_date),
|
|
834
|
+
r.quantity = toInteger($quantity),
|
|
835
|
+
r.total_amount = toFloat($total_amount)
|
|
836
|
+
"""
|
|
837
|
+
result = execute_cypher(driver, cypher, parameters=row, database=database)
|
|
838
|
+
if result.success:
|
|
839
|
+
total_records += result.records_affected
|
|
840
|
+
|
|
841
|
+
console.print("[green]✓[/green] CSV data loaded successfully")
|
|
842
|
+
console.print(f" Records affected: {total_records}")
|
|
843
|
+
return True
|
|
844
|
+
|
|
845
|
+
except Exception as e:
|
|
846
|
+
console.print(f"[red]✗[/red] Error loading CSV data: {e}")
|
|
847
|
+
return False
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@app.command()
|
|
851
|
+
def compile(
|
|
852
|
+
project_dir: Path = typer.Argument(
|
|
853
|
+
Path("."),
|
|
854
|
+
help="Path to grai.build project directory.",
|
|
855
|
+
),
|
|
856
|
+
output_dir: Optional[Path] = typer.Option(
|
|
857
|
+
None,
|
|
858
|
+
"--output",
|
|
859
|
+
"-o",
|
|
860
|
+
help="Output directory for compiled Cypher.",
|
|
861
|
+
),
|
|
862
|
+
):
|
|
863
|
+
"""
|
|
864
|
+
Compile project to Cypher (alias for 'build --skip-validation').
|
|
865
|
+
|
|
866
|
+
Compiles without validation. Use 'build' for validation + compilation.
|
|
867
|
+
"""
|
|
868
|
+
# Call build with skip_validation=True
|
|
869
|
+
build(
|
|
870
|
+
project_dir=project_dir,
|
|
871
|
+
output_dir=output_dir,
|
|
872
|
+
filename="compiled.cypher",
|
|
873
|
+
schema_only=False,
|
|
874
|
+
skip_validation=True,
|
|
875
|
+
verbose=False,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
@app.command()
|
|
880
|
+
def run(
|
|
881
|
+
project_dir: Path = typer.Argument(
|
|
882
|
+
Path("."),
|
|
883
|
+
help="Path to grai.build project directory.",
|
|
884
|
+
),
|
|
885
|
+
uri: str = typer.Option(
|
|
886
|
+
"bolt://localhost:7687",
|
|
887
|
+
"--uri",
|
|
888
|
+
"-u",
|
|
889
|
+
help="Neo4j connection URI.",
|
|
890
|
+
),
|
|
891
|
+
user: str = typer.Option(
|
|
892
|
+
"neo4j",
|
|
893
|
+
"--user",
|
|
894
|
+
help="Neo4j username.",
|
|
895
|
+
),
|
|
896
|
+
password: str = typer.Option(
|
|
897
|
+
...,
|
|
898
|
+
"--password",
|
|
899
|
+
"-p",
|
|
900
|
+
prompt=True,
|
|
901
|
+
hide_input=True,
|
|
902
|
+
help="Neo4j password.",
|
|
903
|
+
),
|
|
904
|
+
database: str = typer.Option(
|
|
905
|
+
"neo4j",
|
|
906
|
+
"--database",
|
|
907
|
+
"-d",
|
|
908
|
+
help="Neo4j database name.",
|
|
909
|
+
),
|
|
910
|
+
cypher_file: Optional[Path] = typer.Option(
|
|
911
|
+
None,
|
|
912
|
+
"--file",
|
|
913
|
+
"-f",
|
|
914
|
+
help="Cypher file to execute (default: target/neo4j/compiled.cypher).",
|
|
915
|
+
),
|
|
916
|
+
schema_only: bool = typer.Option(
|
|
917
|
+
True,
|
|
918
|
+
"--schema-only/--with-data",
|
|
919
|
+
help="Create only schema (constraints/indexes) without data loading statements.",
|
|
920
|
+
),
|
|
921
|
+
load_csv: bool = typer.Option(
|
|
922
|
+
False,
|
|
923
|
+
"--load-csv",
|
|
924
|
+
help="Load CSV data from data/ directory after creating schema.",
|
|
925
|
+
),
|
|
926
|
+
dry_run: bool = typer.Option(
|
|
927
|
+
False,
|
|
928
|
+
"--dry-run",
|
|
929
|
+
help="Show what would be executed without running.",
|
|
930
|
+
),
|
|
931
|
+
skip_build: bool = typer.Option(
|
|
932
|
+
False,
|
|
933
|
+
"--skip-build",
|
|
934
|
+
help="Skip building before execution.",
|
|
935
|
+
),
|
|
936
|
+
verbose: bool = typer.Option(
|
|
937
|
+
False,
|
|
938
|
+
"--verbose",
|
|
939
|
+
"-v",
|
|
940
|
+
help="Show detailed execution output.",
|
|
941
|
+
),
|
|
942
|
+
):
|
|
943
|
+
"""
|
|
944
|
+
Execute compiled Cypher against Neo4j database.
|
|
945
|
+
|
|
946
|
+
By default, only creates the schema (constraints and indexes).
|
|
947
|
+
Use --with-data to also execute data loading statements (requires LOAD CSV context).
|
|
948
|
+
|
|
949
|
+
Builds the project (unless --skip-build) and executes the
|
|
950
|
+
generated Cypher statements against a Neo4j database.
|
|
951
|
+
"""
|
|
952
|
+
from grai.core.loader import (
|
|
953
|
+
close_connection,
|
|
954
|
+
connect_neo4j,
|
|
955
|
+
execute_cypher_file,
|
|
956
|
+
get_database_info,
|
|
957
|
+
verify_connection,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
console.print("\n[bold cyan]🚀 Running project against Neo4j...[/bold cyan]\n")
|
|
961
|
+
|
|
962
|
+
driver = None
|
|
963
|
+
|
|
964
|
+
try:
|
|
965
|
+
# Build project first (unless skipped)
|
|
966
|
+
if not skip_build:
|
|
967
|
+
console.print("[cyan]→[/cyan] Building project...")
|
|
968
|
+
build(
|
|
969
|
+
project_dir=project_dir,
|
|
970
|
+
output_dir=None,
|
|
971
|
+
filename="compiled.cypher",
|
|
972
|
+
schema_only=schema_only,
|
|
973
|
+
skip_validation=False,
|
|
974
|
+
verbose=False,
|
|
975
|
+
)
|
|
976
|
+
console.print()
|
|
977
|
+
|
|
978
|
+
# Determine Cypher file
|
|
979
|
+
if cypher_file is None:
|
|
980
|
+
cypher_file = project_dir / "target" / "neo4j" / "compiled.cypher"
|
|
981
|
+
|
|
982
|
+
if not cypher_file.exists():
|
|
983
|
+
console.print(f"[red]✗ Cypher file not found: {cypher_file}[/red]")
|
|
984
|
+
console.print("[yellow]Hint: Run 'grai build' first[/yellow]")
|
|
985
|
+
raise typer.Exit(code=1)
|
|
986
|
+
|
|
987
|
+
# Show dry run info
|
|
988
|
+
if dry_run:
|
|
989
|
+
console.print("[yellow]🔍 Dry run mode - showing what would be executed[/yellow]\n")
|
|
990
|
+
console.print("[cyan]Connection:[/cyan]")
|
|
991
|
+
console.print(f" URI: {uri}")
|
|
992
|
+
console.print(f" User: {user}")
|
|
993
|
+
console.print(f" Database: {database}")
|
|
994
|
+
console.print(f"\n[cyan]Cypher file:[/cyan] {cypher_file}\n")
|
|
995
|
+
|
|
996
|
+
# Show first few lines of Cypher
|
|
997
|
+
cypher_content = cypher_file.read_text()
|
|
998
|
+
lines = cypher_content.split("\n")[:20]
|
|
999
|
+
console.print("[cyan]First 20 lines of Cypher:[/cyan]")
|
|
1000
|
+
for line in lines:
|
|
1001
|
+
console.print(f" {line}")
|
|
1002
|
+
|
|
1003
|
+
if len(cypher_content.split("\n")) > 20:
|
|
1004
|
+
console.print(" ...")
|
|
1005
|
+
|
|
1006
|
+
console.print("\n[yellow]ℹ️ Run without --dry-run to execute[/yellow]")
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
# Connect to Neo4j
|
|
1010
|
+
console.print(f"[cyan]→[/cyan] Connecting to Neo4j at {uri}...")
|
|
1011
|
+
|
|
1012
|
+
try:
|
|
1013
|
+
driver = connect_neo4j(
|
|
1014
|
+
uri=uri,
|
|
1015
|
+
user=user,
|
|
1016
|
+
password=password,
|
|
1017
|
+
database=database,
|
|
1018
|
+
)
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
console.print(f"[red]✗ Connection failed: {e}[/red]")
|
|
1021
|
+
console.print("\n[yellow]Troubleshooting tips:[/yellow]")
|
|
1022
|
+
console.print(" 1. Check that Neo4j is running")
|
|
1023
|
+
console.print(" 2. Verify the URI is correct")
|
|
1024
|
+
console.print(" 3. Check username and password")
|
|
1025
|
+
raise typer.Exit(code=1)
|
|
1026
|
+
|
|
1027
|
+
console.print("[green]✓[/green] Connected to Neo4j")
|
|
1028
|
+
|
|
1029
|
+
# Verify connection
|
|
1030
|
+
if not verify_connection(driver, database):
|
|
1031
|
+
console.print(f"[red]✗ Cannot access database: {database}[/red]")
|
|
1032
|
+
raise typer.Exit(code=1)
|
|
1033
|
+
|
|
1034
|
+
# Get database info before execution
|
|
1035
|
+
if verbose:
|
|
1036
|
+
console.print("\n[cyan]Database info (before execution):[/cyan]")
|
|
1037
|
+
info = get_database_info(driver, database)
|
|
1038
|
+
console.print(f" Nodes: {info.get('node_count', 0)}")
|
|
1039
|
+
console.print(f" Relationships: {info.get('relationship_count', 0)}")
|
|
1040
|
+
console.print(f" Labels: {', '.join(info.get('labels', []))}")
|
|
1041
|
+
console.print()
|
|
1042
|
+
|
|
1043
|
+
# Execute Cypher
|
|
1044
|
+
console.print(f"[cyan]→[/cyan] Executing Cypher from {cypher_file.name}...")
|
|
1045
|
+
|
|
1046
|
+
result = execute_cypher_file(driver, cypher_file, database=database)
|
|
1047
|
+
|
|
1048
|
+
if result.success:
|
|
1049
|
+
console.print("[green]✓[/green] Execution successful")
|
|
1050
|
+
console.print(f" Statements executed: {result.statements_executed}")
|
|
1051
|
+
console.print(f" Records affected: {result.records_affected}")
|
|
1052
|
+
console.print(f" Execution time: {result.execution_time:.2f}s")
|
|
1053
|
+
|
|
1054
|
+
# Load CSV data if requested
|
|
1055
|
+
if load_csv:
|
|
1056
|
+
console.print("\n[cyan]→[/cyan] Loading CSV data...")
|
|
1057
|
+
csv_result = _load_csv_data(driver, project_dir, database, verbose)
|
|
1058
|
+
|
|
1059
|
+
if not csv_result:
|
|
1060
|
+
console.print(
|
|
1061
|
+
"[yellow]⚠ No CSV data loaded (load_data.cypher not found or failed)[/yellow]"
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
# Get database info after execution
|
|
1065
|
+
if verbose:
|
|
1066
|
+
console.print("\n[cyan]Database info (after execution):[/cyan]")
|
|
1067
|
+
info = get_database_info(driver, database)
|
|
1068
|
+
console.print(f" Nodes: {info.get('node_count', 0)}")
|
|
1069
|
+
console.print(f" Relationships: {info.get('relationship_count', 0)}")
|
|
1070
|
+
console.print(f" Labels: {', '.join(info.get('labels', []))}")
|
|
1071
|
+
|
|
1072
|
+
console.print("\n[bold green]✓ Successfully loaded data into Neo4j![/bold green]\n")
|
|
1073
|
+
else:
|
|
1074
|
+
console.print("[bold red]✗ Execution failed![/bold red]\n")
|
|
1075
|
+
|
|
1076
|
+
for error in result.errors:
|
|
1077
|
+
console.print(f" [red]✗[/red] {error}")
|
|
1078
|
+
|
|
1079
|
+
console.print()
|
|
1080
|
+
raise typer.Exit(code=1)
|
|
1081
|
+
|
|
1082
|
+
except typer.Exit:
|
|
1083
|
+
raise
|
|
1084
|
+
except KeyboardInterrupt:
|
|
1085
|
+
console.print("\n[yellow]⚠ Interrupted by user[/yellow]")
|
|
1086
|
+
raise typer.Exit(code=130)
|
|
1087
|
+
except Exception as e:
|
|
1088
|
+
console.print(f"[red]✗ Error during execution: {e}[/red]")
|
|
1089
|
+
raise typer.Exit(code=1)
|
|
1090
|
+
finally:
|
|
1091
|
+
if driver:
|
|
1092
|
+
close_connection(driver)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
@app.command()
|
|
1096
|
+
def export(
|
|
1097
|
+
project_dir: Path = typer.Argument(
|
|
1098
|
+
Path("."),
|
|
1099
|
+
help="Path to grai.build project directory.",
|
|
1100
|
+
),
|
|
1101
|
+
output: Optional[Path] = typer.Option(
|
|
1102
|
+
None,
|
|
1103
|
+
"--output",
|
|
1104
|
+
"-o",
|
|
1105
|
+
help="Output file path (default: graph-ir.json in project directory).",
|
|
1106
|
+
),
|
|
1107
|
+
format: str = typer.Option(
|
|
1108
|
+
"json",
|
|
1109
|
+
"--format",
|
|
1110
|
+
"-f",
|
|
1111
|
+
help="Export format (currently only 'json' supported).",
|
|
1112
|
+
),
|
|
1113
|
+
pretty: bool = typer.Option(
|
|
1114
|
+
True,
|
|
1115
|
+
"--pretty/--compact",
|
|
1116
|
+
help="Pretty-print JSON output.",
|
|
1117
|
+
),
|
|
1118
|
+
indent: int = typer.Option(
|
|
1119
|
+
2,
|
|
1120
|
+
"--indent",
|
|
1121
|
+
"-i",
|
|
1122
|
+
help="Number of spaces for JSON indentation.",
|
|
1123
|
+
),
|
|
1124
|
+
):
|
|
1125
|
+
"""
|
|
1126
|
+
Export project to Graph IR (Intermediate Representation).
|
|
1127
|
+
|
|
1128
|
+
Generates a JSON representation of the complete graph structure
|
|
1129
|
+
including entities, relations, properties, and metadata.
|
|
1130
|
+
"""
|
|
1131
|
+
from grai.core.exporter import write_ir_file
|
|
1132
|
+
|
|
1133
|
+
console.print("\n[bold cyan]📤 Exporting project to Graph IR...[/bold cyan]\n")
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
# Load project
|
|
1137
|
+
project = load_project(project_dir)
|
|
1138
|
+
console.print(
|
|
1139
|
+
f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan] (v{project.version})"
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
# Determine output path
|
|
1143
|
+
if output is None:
|
|
1144
|
+
output = project_dir / "graph-ir.json"
|
|
1145
|
+
|
|
1146
|
+
# Validate format
|
|
1147
|
+
if format.lower() != "json":
|
|
1148
|
+
console.print(f"[red]✗ Unsupported format: {format}[/red]")
|
|
1149
|
+
console.print("[yellow]Currently only 'json' format is supported[/yellow]")
|
|
1150
|
+
raise typer.Exit(code=1)
|
|
1151
|
+
|
|
1152
|
+
# Export to file
|
|
1153
|
+
console.print(f"[cyan]→[/cyan] Exporting to {output}...")
|
|
1154
|
+
write_ir_file(project, output, pretty=pretty, indent=indent)
|
|
1155
|
+
|
|
1156
|
+
# Show statistics
|
|
1157
|
+
from grai.core.exporter import export_to_ir
|
|
1158
|
+
|
|
1159
|
+
ir = export_to_ir(project)
|
|
1160
|
+
stats = ir["statistics"]
|
|
1161
|
+
|
|
1162
|
+
console.print("[green]✓[/green] Export complete!")
|
|
1163
|
+
console.print("\n[cyan]Statistics:[/cyan]")
|
|
1164
|
+
console.print(f" Entities: {stats['entity_count']}")
|
|
1165
|
+
console.print(f" Relations: {stats['relation_count']}")
|
|
1166
|
+
console.print(f" Total Properties: {stats['total_properties']}")
|
|
1167
|
+
console.print(f" File size: {output.stat().st_size:,} bytes")
|
|
1168
|
+
|
|
1169
|
+
console.print(f"\n[bold green]✓ Graph IR exported to: {output}[/bold green]\n")
|
|
1170
|
+
|
|
1171
|
+
except FileNotFoundError as e:
|
|
1172
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1173
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
1174
|
+
raise typer.Exit(code=1)
|
|
1175
|
+
except Exception as e:
|
|
1176
|
+
console.print(f"[red]✗ Error during export: {e}[/red]")
|
|
1177
|
+
raise typer.Exit(code=1)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
@app.command()
|
|
1181
|
+
def info(
|
|
1182
|
+
project_dir: Path = typer.Argument(
|
|
1183
|
+
Path("."),
|
|
1184
|
+
help="Path to grai.build project directory.",
|
|
1185
|
+
),
|
|
1186
|
+
):
|
|
1187
|
+
"""
|
|
1188
|
+
Show project information and statistics.
|
|
1189
|
+
"""
|
|
1190
|
+
console.print("\n[bold cyan]📊 Project Information[/bold cyan]\n")
|
|
1191
|
+
|
|
1192
|
+
try:
|
|
1193
|
+
# Load project
|
|
1194
|
+
project = load_project(project_dir)
|
|
1195
|
+
|
|
1196
|
+
# Create info table
|
|
1197
|
+
table = Table(title=f"Project: {project.name}", show_header=False)
|
|
1198
|
+
table.add_column("Property", style="cyan", width=20)
|
|
1199
|
+
table.add_column("Value", style="white")
|
|
1200
|
+
|
|
1201
|
+
table.add_row("Name", project.name)
|
|
1202
|
+
table.add_row("Version", project.version)
|
|
1203
|
+
table.add_row("Entities", str(len(project.entities)))
|
|
1204
|
+
table.add_row("Relations", str(len(project.relations)))
|
|
1205
|
+
|
|
1206
|
+
# Count total properties
|
|
1207
|
+
total_entity_props = sum(len(e.properties) for e in project.entities)
|
|
1208
|
+
total_relation_props = sum(len(r.properties) for r in project.relations)
|
|
1209
|
+
|
|
1210
|
+
table.add_row("Entity Properties", str(total_entity_props))
|
|
1211
|
+
table.add_row("Relation Properties", str(total_relation_props))
|
|
1212
|
+
|
|
1213
|
+
console.print(table)
|
|
1214
|
+
console.print()
|
|
1215
|
+
|
|
1216
|
+
# Show entities
|
|
1217
|
+
if project.entities:
|
|
1218
|
+
entity_table = Table(title="Entities")
|
|
1219
|
+
entity_table.add_column("Entity", style="cyan")
|
|
1220
|
+
entity_table.add_column("Source", style="white")
|
|
1221
|
+
entity_table.add_column("Keys", style="yellow")
|
|
1222
|
+
entity_table.add_column("Properties", style="green")
|
|
1223
|
+
|
|
1224
|
+
for entity in project.entities:
|
|
1225
|
+
entity_table.add_row(
|
|
1226
|
+
entity.entity,
|
|
1227
|
+
entity.get_source_name(),
|
|
1228
|
+
", ".join(entity.keys),
|
|
1229
|
+
str(len(entity.properties)),
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
console.print(entity_table)
|
|
1233
|
+
console.print()
|
|
1234
|
+
|
|
1235
|
+
# Show relations
|
|
1236
|
+
if project.relations:
|
|
1237
|
+
relation_table = Table(title="Relations")
|
|
1238
|
+
relation_table.add_column("Relation", style="cyan")
|
|
1239
|
+
relation_table.add_column("From → To", style="white")
|
|
1240
|
+
relation_table.add_column("Source", style="white")
|
|
1241
|
+
relation_table.add_column("Properties", style="green")
|
|
1242
|
+
|
|
1243
|
+
for relation in project.relations:
|
|
1244
|
+
relation_table.add_row(
|
|
1245
|
+
relation.relation,
|
|
1246
|
+
f"{relation.from_entity} → {relation.to_entity}",
|
|
1247
|
+
relation.get_source_name(),
|
|
1248
|
+
str(len(relation.properties)),
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
console.print(relation_table)
|
|
1252
|
+
console.print()
|
|
1253
|
+
|
|
1254
|
+
except FileNotFoundError as e:
|
|
1255
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1256
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
1257
|
+
raise typer.Exit(code=1)
|
|
1258
|
+
except Exception as e:
|
|
1259
|
+
console.print(f"[red]✗ Error loading project: {e}[/red]")
|
|
1260
|
+
raise typer.Exit(code=1)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
@app.command()
|
|
1264
|
+
def cache(
|
|
1265
|
+
project_dir: Path = typer.Argument(
|
|
1266
|
+
Path("."),
|
|
1267
|
+
help="Path to grai.build project directory.",
|
|
1268
|
+
),
|
|
1269
|
+
clear: bool = typer.Option(
|
|
1270
|
+
False,
|
|
1271
|
+
"--clear",
|
|
1272
|
+
"-c",
|
|
1273
|
+
help="Clear the build cache.",
|
|
1274
|
+
),
|
|
1275
|
+
show: bool = typer.Option(
|
|
1276
|
+
False,
|
|
1277
|
+
"--show",
|
|
1278
|
+
"-s",
|
|
1279
|
+
help="Show cache contents.",
|
|
1280
|
+
),
|
|
1281
|
+
):
|
|
1282
|
+
"""
|
|
1283
|
+
Manage build cache for incremental builds.
|
|
1284
|
+
|
|
1285
|
+
View cache information or clear cached build data.
|
|
1286
|
+
"""
|
|
1287
|
+
console.print("\n[bold cyan]💾 Build Cache Management[/bold cyan]\n")
|
|
1288
|
+
|
|
1289
|
+
try:
|
|
1290
|
+
if clear:
|
|
1291
|
+
# Clear cache
|
|
1292
|
+
if clear_cache(project_dir):
|
|
1293
|
+
console.print("[green]✓[/green] Cache cleared successfully")
|
|
1294
|
+
else:
|
|
1295
|
+
console.print("[yellow]⚠[/yellow] No cache found")
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
# Load and show cache info
|
|
1299
|
+
build_cache = load_cache(project_dir)
|
|
1300
|
+
|
|
1301
|
+
if build_cache is None:
|
|
1302
|
+
console.print("[yellow]⚠[/yellow] No cache found")
|
|
1303
|
+
console.print("[dim]Run 'grai build' to create cache[/dim]")
|
|
1304
|
+
return
|
|
1305
|
+
|
|
1306
|
+
# Show cache summary
|
|
1307
|
+
console.print(f"[cyan]Project:[/cyan] {build_cache.project_name or 'Unknown'}")
|
|
1308
|
+
console.print(f"[cyan]Version:[/cyan] {build_cache.project_version or 'Unknown'}")
|
|
1309
|
+
console.print(f"[cyan]Created:[/cyan] {build_cache.created_at}")
|
|
1310
|
+
console.print(f"[cyan]Updated:[/cyan] {build_cache.last_updated}")
|
|
1311
|
+
console.print(f"[cyan]Cached files:[/cyan] {len(build_cache.entries)}")
|
|
1312
|
+
console.print()
|
|
1313
|
+
|
|
1314
|
+
if show and build_cache.entries:
|
|
1315
|
+
# Show detailed cache entries
|
|
1316
|
+
table = Table(title="Cached Files")
|
|
1317
|
+
table.add_column("File", style="cyan")
|
|
1318
|
+
table.add_column("Hash", style="white")
|
|
1319
|
+
table.add_column("Size", style="green")
|
|
1320
|
+
table.add_column("Modified", style="yellow")
|
|
1321
|
+
|
|
1322
|
+
for path, entry in sorted(build_cache.entries.items()):
|
|
1323
|
+
# Format size
|
|
1324
|
+
size_kb = entry.size / 1024
|
|
1325
|
+
size_str = f"{size_kb:.1f} KB" if size_kb > 1 else f"{entry.size} B"
|
|
1326
|
+
|
|
1327
|
+
# Truncate hash for display
|
|
1328
|
+
short_hash = entry.hash[:12] + "..."
|
|
1329
|
+
|
|
1330
|
+
# Format timestamp
|
|
1331
|
+
try:
|
|
1332
|
+
from datetime import datetime
|
|
1333
|
+
|
|
1334
|
+
dt = datetime.fromisoformat(entry.last_modified.replace("Z", "+00:00"))
|
|
1335
|
+
time_str = dt.strftime("%Y-%m-%d %H:%M")
|
|
1336
|
+
except Exception: # noqa: BLE001
|
|
1337
|
+
time_str = entry.last_modified[:16]
|
|
1338
|
+
|
|
1339
|
+
table.add_row(path, short_hash, size_str, time_str)
|
|
1340
|
+
|
|
1341
|
+
console.print(table)
|
|
1342
|
+
console.print()
|
|
1343
|
+
|
|
1344
|
+
# Check for changes
|
|
1345
|
+
needs_rebuild, changes = should_rebuild(project_dir, build_cache)
|
|
1346
|
+
|
|
1347
|
+
if needs_rebuild:
|
|
1348
|
+
total_changes = sum(len(files) for files in changes.values())
|
|
1349
|
+
console.print(f"[yellow]⚠[/yellow] {total_changes} file(s) changed since last build")
|
|
1350
|
+
|
|
1351
|
+
if changes["added"]:
|
|
1352
|
+
console.print(f" [green]+[/green] Added: {len(changes['added'])} file(s)")
|
|
1353
|
+
if show:
|
|
1354
|
+
for file in sorted(changes["added"]):
|
|
1355
|
+
console.print(f" - {file.relative_to(project_dir)}")
|
|
1356
|
+
|
|
1357
|
+
if changes["modified"]:
|
|
1358
|
+
console.print(f" [yellow]~[/yellow] Modified: {len(changes['modified'])} file(s)")
|
|
1359
|
+
if show:
|
|
1360
|
+
for file in sorted(changes["modified"]):
|
|
1361
|
+
console.print(f" - {file.relative_to(project_dir)}")
|
|
1362
|
+
|
|
1363
|
+
if changes["deleted"]:
|
|
1364
|
+
console.print(f" [red]-[/red] Deleted: {len(changes['deleted'])} file(s)")
|
|
1365
|
+
if show:
|
|
1366
|
+
for file in sorted(changes["deleted"]):
|
|
1367
|
+
console.print(f" - {file.relative_to(project_dir)}")
|
|
1368
|
+
else:
|
|
1369
|
+
console.print("[green]✓[/green] Build is up to date")
|
|
1370
|
+
|
|
1371
|
+
except Exception as e:
|
|
1372
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1373
|
+
raise typer.Exit(code=1)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
@app.command()
|
|
1377
|
+
def lineage(
|
|
1378
|
+
project_dir: Path = typer.Argument(
|
|
1379
|
+
Path("."),
|
|
1380
|
+
help="Path to grai.build project directory.",
|
|
1381
|
+
),
|
|
1382
|
+
entity: Optional[str] = typer.Option(
|
|
1383
|
+
None,
|
|
1384
|
+
"--entity",
|
|
1385
|
+
"-e",
|
|
1386
|
+
help="Show lineage for specific entity.",
|
|
1387
|
+
),
|
|
1388
|
+
relation: Optional[str] = typer.Option(
|
|
1389
|
+
None,
|
|
1390
|
+
"--relation",
|
|
1391
|
+
"-r",
|
|
1392
|
+
help="Show lineage for specific relation.",
|
|
1393
|
+
),
|
|
1394
|
+
impact: Optional[str] = typer.Option(
|
|
1395
|
+
None,
|
|
1396
|
+
"--impact",
|
|
1397
|
+
"-i",
|
|
1398
|
+
help="Calculate impact analysis for entity.",
|
|
1399
|
+
),
|
|
1400
|
+
visualize: Optional[str] = typer.Option(
|
|
1401
|
+
None,
|
|
1402
|
+
"--visualize",
|
|
1403
|
+
"-v",
|
|
1404
|
+
help="Generate visualization (mermaid or graphviz).",
|
|
1405
|
+
),
|
|
1406
|
+
output: Optional[Path] = typer.Option(
|
|
1407
|
+
None,
|
|
1408
|
+
"--output",
|
|
1409
|
+
"-o",
|
|
1410
|
+
help="Output file for visualization.",
|
|
1411
|
+
),
|
|
1412
|
+
focus: Optional[str] = typer.Option(
|
|
1413
|
+
None,
|
|
1414
|
+
"--focus",
|
|
1415
|
+
"-f",
|
|
1416
|
+
help="Focus visualization on specific entity.",
|
|
1417
|
+
),
|
|
1418
|
+
):
|
|
1419
|
+
"""
|
|
1420
|
+
Analyze lineage and dependencies in the knowledge graph.
|
|
1421
|
+
|
|
1422
|
+
Track entity relationships, calculate impact, and visualize dependencies.
|
|
1423
|
+
"""
|
|
1424
|
+
console.print("\n[bold cyan]🔍 Lineage Analysis[/bold cyan]\n")
|
|
1425
|
+
|
|
1426
|
+
try:
|
|
1427
|
+
# Load project
|
|
1428
|
+
project = load_project(project_dir)
|
|
1429
|
+
console.print(f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan]")
|
|
1430
|
+
|
|
1431
|
+
# Build lineage graph
|
|
1432
|
+
console.print("[cyan]→[/cyan] Building lineage graph...")
|
|
1433
|
+
graph = build_lineage_graph(project)
|
|
1434
|
+
console.print(
|
|
1435
|
+
f"[green]✓[/green] Built graph with {len(graph.nodes)} nodes and {len(graph.edges)} edges"
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
# Show entity lineage
|
|
1439
|
+
if entity:
|
|
1440
|
+
console.print(f"\n[bold]Entity Lineage: {entity}[/bold]\n")
|
|
1441
|
+
lineage = get_entity_lineage(graph, entity)
|
|
1442
|
+
|
|
1443
|
+
if "error" in lineage:
|
|
1444
|
+
console.print(f"[red]✗ {lineage['error']}[/red]")
|
|
1445
|
+
raise typer.Exit(code=1)
|
|
1446
|
+
|
|
1447
|
+
# Show source
|
|
1448
|
+
console.print(f"[cyan]Source:[/cyan] {lineage['source']}")
|
|
1449
|
+
|
|
1450
|
+
# Show upstream
|
|
1451
|
+
if lineage["upstream"]:
|
|
1452
|
+
console.print(f"\n[cyan]Upstream ({len(lineage['upstream'])}):[/cyan]")
|
|
1453
|
+
for up in lineage["upstream"]:
|
|
1454
|
+
console.print(f" ← {up['node']} ({up['type']}) via {up['relation']}")
|
|
1455
|
+
else:
|
|
1456
|
+
console.print("\n[dim]No upstream dependencies[/dim]")
|
|
1457
|
+
|
|
1458
|
+
# Show downstream
|
|
1459
|
+
if lineage["downstream"]:
|
|
1460
|
+
console.print(f"\n[cyan]Downstream ({len(lineage['downstream'])}):[/cyan]")
|
|
1461
|
+
for down in lineage["downstream"]:
|
|
1462
|
+
console.print(f" → {down['node']} ({down['type']}) via {down['relation']}")
|
|
1463
|
+
else:
|
|
1464
|
+
console.print("\n[dim]No downstream dependencies[/dim]")
|
|
1465
|
+
|
|
1466
|
+
# Show relation lineage
|
|
1467
|
+
elif relation:
|
|
1468
|
+
console.print(f"\n[bold]Relation Lineage: {relation}[/bold]\n")
|
|
1469
|
+
lineage = get_relation_lineage(graph, relation)
|
|
1470
|
+
|
|
1471
|
+
if "error" in lineage:
|
|
1472
|
+
console.print(f"[red]✗ {lineage['error']}[/red]")
|
|
1473
|
+
raise typer.Exit(code=1)
|
|
1474
|
+
|
|
1475
|
+
# Show connection
|
|
1476
|
+
console.print(
|
|
1477
|
+
f"[cyan]Connects:[/cyan] {lineage['from_entity']} → {lineage['to_entity']}"
|
|
1478
|
+
)
|
|
1479
|
+
console.print(f"[cyan]Source:[/cyan] {lineage['source']}")
|
|
1480
|
+
|
|
1481
|
+
# Show upstream
|
|
1482
|
+
if lineage["upstream"]:
|
|
1483
|
+
console.print(f"\n[cyan]Upstream ({len(lineage['upstream'])}):[/cyan]")
|
|
1484
|
+
for up in lineage["upstream"]:
|
|
1485
|
+
console.print(f" ← {up['node']} ({up['type']}) via {up['relation']}")
|
|
1486
|
+
|
|
1487
|
+
# Show downstream
|
|
1488
|
+
if lineage["downstream"]:
|
|
1489
|
+
console.print(f"\n[cyan]Downstream ({len(lineage['downstream'])}):[/cyan]")
|
|
1490
|
+
for down in lineage["downstream"]:
|
|
1491
|
+
console.print(f" → {down['node']} ({down['type']}) via {down['relation']}")
|
|
1492
|
+
|
|
1493
|
+
# Calculate impact
|
|
1494
|
+
elif impact:
|
|
1495
|
+
console.print(f"\n[bold]Impact Analysis: {impact}[/bold]\n")
|
|
1496
|
+
analysis = calculate_impact_analysis(graph, impact)
|
|
1497
|
+
|
|
1498
|
+
if "error" in analysis:
|
|
1499
|
+
console.print(f"[red]✗ {analysis['error']}[/red]")
|
|
1500
|
+
raise typer.Exit(code=1)
|
|
1501
|
+
|
|
1502
|
+
# Show impact score
|
|
1503
|
+
level_color = {
|
|
1504
|
+
"none": "dim",
|
|
1505
|
+
"low": "green",
|
|
1506
|
+
"medium": "yellow",
|
|
1507
|
+
"high": "red",
|
|
1508
|
+
}
|
|
1509
|
+
color = level_color.get(analysis["impact_level"], "white")
|
|
1510
|
+
|
|
1511
|
+
console.print(f"[cyan]Impact Score:[/cyan] {analysis['impact_score']}")
|
|
1512
|
+
console.print(
|
|
1513
|
+
f"[cyan]Impact Level:[/cyan] [{color}]{analysis['impact_level'].upper()}[/{color}]"
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
# Show affected entities
|
|
1517
|
+
if analysis["affected_entities"]:
|
|
1518
|
+
console.print(
|
|
1519
|
+
f"\n[cyan]Affected Entities ({len(analysis['affected_entities'])}):[/cyan]"
|
|
1520
|
+
)
|
|
1521
|
+
for ent in analysis["affected_entities"]:
|
|
1522
|
+
console.print(f" • {ent}")
|
|
1523
|
+
else:
|
|
1524
|
+
console.print("\n[dim]No affected entities[/dim]")
|
|
1525
|
+
|
|
1526
|
+
# Show affected relations
|
|
1527
|
+
if analysis["affected_relations"]:
|
|
1528
|
+
console.print(
|
|
1529
|
+
f"\n[cyan]Affected Relations ({len(analysis['affected_relations'])}):[/cyan]"
|
|
1530
|
+
)
|
|
1531
|
+
for rel in analysis["affected_relations"]:
|
|
1532
|
+
console.print(f" • {rel}")
|
|
1533
|
+
|
|
1534
|
+
# Generate visualization
|
|
1535
|
+
elif visualize:
|
|
1536
|
+
console.print(f"\n[bold]Generating {visualize.upper()} visualization...[/bold]\n")
|
|
1537
|
+
|
|
1538
|
+
if visualize.lower() == "mermaid":
|
|
1539
|
+
diagram = visualize_lineage_mermaid(graph, focus_entity=focus)
|
|
1540
|
+
elif visualize.lower() == "graphviz" or visualize.lower() == "dot":
|
|
1541
|
+
diagram = visualize_lineage_graphviz(graph, focus_entity=focus)
|
|
1542
|
+
else:
|
|
1543
|
+
console.print(f"[red]✗ Unknown visualization format: {visualize}[/red]")
|
|
1544
|
+
console.print("[yellow]Use 'mermaid' or 'graphviz'[/yellow]")
|
|
1545
|
+
raise typer.Exit(code=1)
|
|
1546
|
+
|
|
1547
|
+
# Save to file or print
|
|
1548
|
+
if output:
|
|
1549
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
1550
|
+
output.write_text(diagram)
|
|
1551
|
+
console.print(f"[green]✓[/green] Wrote visualization to: [cyan]{output}[/cyan]")
|
|
1552
|
+
else:
|
|
1553
|
+
console.print(diagram)
|
|
1554
|
+
|
|
1555
|
+
# Show general statistics
|
|
1556
|
+
else:
|
|
1557
|
+
console.print("\n[bold]Lineage Statistics[/bold]\n")
|
|
1558
|
+
stats = get_lineage_statistics(graph)
|
|
1559
|
+
|
|
1560
|
+
table = Table()
|
|
1561
|
+
table.add_column("Metric", style="cyan")
|
|
1562
|
+
table.add_column("Value", style="white")
|
|
1563
|
+
|
|
1564
|
+
table.add_row("Total Nodes", str(stats["total_nodes"]))
|
|
1565
|
+
table.add_row("Total Edges", str(stats["total_edges"]))
|
|
1566
|
+
table.add_row("Entities", str(stats["entity_count"]))
|
|
1567
|
+
table.add_row("Relations", str(stats["relation_count"]))
|
|
1568
|
+
table.add_row("Sources", str(stats["source_count"]))
|
|
1569
|
+
table.add_row("Max Downstream", str(stats["max_downstream_connections"]))
|
|
1570
|
+
if stats["most_connected_entity"]:
|
|
1571
|
+
table.add_row("Most Connected", stats["most_connected_entity"])
|
|
1572
|
+
|
|
1573
|
+
console.print(table)
|
|
1574
|
+
console.print()
|
|
1575
|
+
|
|
1576
|
+
console.print(
|
|
1577
|
+
"[dim]Use --entity, --relation, --impact, or --visualize for detailed analysis[/dim]"
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
except FileNotFoundError as e:
|
|
1581
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1582
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
1583
|
+
raise typer.Exit(code=1)
|
|
1584
|
+
except Exception as e:
|
|
1585
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1586
|
+
raise typer.Exit(code=1)
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
@app.command()
|
|
1590
|
+
def visualize(
|
|
1591
|
+
project_dir: Path = typer.Argument(
|
|
1592
|
+
Path("."),
|
|
1593
|
+
help="Path to grai.build project directory.",
|
|
1594
|
+
),
|
|
1595
|
+
output: Path = typer.Option(
|
|
1596
|
+
Path("graph.html"),
|
|
1597
|
+
"--output",
|
|
1598
|
+
"-o",
|
|
1599
|
+
help="Output HTML file path.",
|
|
1600
|
+
),
|
|
1601
|
+
format: str = typer.Option(
|
|
1602
|
+
"d3",
|
|
1603
|
+
"--format",
|
|
1604
|
+
"-f",
|
|
1605
|
+
help="Visualization format: d3 or cytoscape.",
|
|
1606
|
+
),
|
|
1607
|
+
title: Optional[str] = typer.Option(
|
|
1608
|
+
None,
|
|
1609
|
+
"--title",
|
|
1610
|
+
"-t",
|
|
1611
|
+
help="Custom title for visualization (defaults to project name).",
|
|
1612
|
+
),
|
|
1613
|
+
width: int = typer.Option(
|
|
1614
|
+
1200,
|
|
1615
|
+
"--width",
|
|
1616
|
+
"-w",
|
|
1617
|
+
help="Width of visualization canvas in pixels.",
|
|
1618
|
+
),
|
|
1619
|
+
height: int = typer.Option(
|
|
1620
|
+
800,
|
|
1621
|
+
"--height",
|
|
1622
|
+
"-h",
|
|
1623
|
+
help="Height of visualization canvas in pixels.",
|
|
1624
|
+
),
|
|
1625
|
+
open_browser: bool = typer.Option(
|
|
1626
|
+
False,
|
|
1627
|
+
"--open",
|
|
1628
|
+
help="Open visualization in default browser after generation.",
|
|
1629
|
+
),
|
|
1630
|
+
):
|
|
1631
|
+
"""
|
|
1632
|
+
Generate interactive HTML visualization of the knowledge graph.
|
|
1633
|
+
|
|
1634
|
+
Creates an interactive web-based visualization using D3.js or Cytoscape.js.
|
|
1635
|
+
The resulting HTML file can be opened in any modern web browser.
|
|
1636
|
+
"""
|
|
1637
|
+
console.print("\n[bold cyan]🎨 Generating Interactive Visualization[/bold cyan]\n")
|
|
1638
|
+
|
|
1639
|
+
try:
|
|
1640
|
+
# Load project
|
|
1641
|
+
project = load_project(project_dir)
|
|
1642
|
+
console.print(f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan]")
|
|
1643
|
+
|
|
1644
|
+
# Generate visualization based on format
|
|
1645
|
+
console.print(f"[cyan]→[/cyan] Generating {format.upper()} visualization...")
|
|
1646
|
+
|
|
1647
|
+
if format.lower() == "d3":
|
|
1648
|
+
generate_d3_visualization(
|
|
1649
|
+
project=project,
|
|
1650
|
+
output_path=output,
|
|
1651
|
+
title=title,
|
|
1652
|
+
width=width,
|
|
1653
|
+
height=height,
|
|
1654
|
+
)
|
|
1655
|
+
elif format.lower() == "cytoscape":
|
|
1656
|
+
generate_cytoscape_visualization(
|
|
1657
|
+
project=project,
|
|
1658
|
+
output_path=output,
|
|
1659
|
+
title=title,
|
|
1660
|
+
width=width,
|
|
1661
|
+
height=height,
|
|
1662
|
+
)
|
|
1663
|
+
else:
|
|
1664
|
+
console.print(f"[red]✗ Unknown format: {format}[/red]")
|
|
1665
|
+
console.print("[yellow]Supported formats: d3, cytoscape[/yellow]")
|
|
1666
|
+
raise typer.Exit(code=1)
|
|
1667
|
+
|
|
1668
|
+
console.print(f"[green]✓[/green] Generated visualization: [cyan]{output}[/cyan]")
|
|
1669
|
+
console.print(f"[dim] Size: {output.stat().st_size:,} bytes[/dim]")
|
|
1670
|
+
console.print()
|
|
1671
|
+
console.print(
|
|
1672
|
+
"[bold]📱 Open the HTML file in your browser to view the interactive graph![/bold]"
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
# Optionally open in browser
|
|
1676
|
+
if open_browser:
|
|
1677
|
+
import webbrowser
|
|
1678
|
+
|
|
1679
|
+
console.print("[cyan]→[/cyan] Opening in browser...")
|
|
1680
|
+
webbrowser.open(f"file://{output.absolute()}")
|
|
1681
|
+
|
|
1682
|
+
except FileNotFoundError as e:
|
|
1683
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1684
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
1685
|
+
raise typer.Exit(code=1)
|
|
1686
|
+
except Exception as e:
|
|
1687
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1688
|
+
raise typer.Exit(code=1)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
@app.command()
|
|
1692
|
+
def docs(
|
|
1693
|
+
project_dir: Path = typer.Argument(
|
|
1694
|
+
Path("."),
|
|
1695
|
+
help="Path to grai.build project directory.",
|
|
1696
|
+
),
|
|
1697
|
+
output_dir: Path = typer.Option(
|
|
1698
|
+
Path("target/docs"),
|
|
1699
|
+
"--output",
|
|
1700
|
+
"-o",
|
|
1701
|
+
help="Output directory for documentation.",
|
|
1702
|
+
),
|
|
1703
|
+
serve: bool = typer.Option(
|
|
1704
|
+
False,
|
|
1705
|
+
"--serve",
|
|
1706
|
+
"-s",
|
|
1707
|
+
help="Start local server to view documentation.",
|
|
1708
|
+
),
|
|
1709
|
+
port: int = typer.Option(
|
|
1710
|
+
8080,
|
|
1711
|
+
"--port",
|
|
1712
|
+
"-p",
|
|
1713
|
+
help="Port for documentation server.",
|
|
1714
|
+
),
|
|
1715
|
+
open_browser: bool = typer.Option(
|
|
1716
|
+
True,
|
|
1717
|
+
"--open/--no-open",
|
|
1718
|
+
help="Open documentation in browser when serving.",
|
|
1719
|
+
),
|
|
1720
|
+
):
|
|
1721
|
+
"""
|
|
1722
|
+
Generate and serve interactive documentation for your knowledge graph.
|
|
1723
|
+
|
|
1724
|
+
Similar to 'dbt docs generate/serve', this command creates comprehensive
|
|
1725
|
+
HTML documentation including:
|
|
1726
|
+
- Entity and relation catalogs
|
|
1727
|
+
- Interactive graph visualization
|
|
1728
|
+
- Lineage diagrams
|
|
1729
|
+
- Searchable property reference
|
|
1730
|
+
|
|
1731
|
+
Examples:
|
|
1732
|
+
grai docs # Generate docs in target/docs
|
|
1733
|
+
grai docs --serve # Generate and serve on http://localhost:8080
|
|
1734
|
+
grai docs --serve --port 3000 # Serve on custom port
|
|
1735
|
+
grai docs --output ./my-docs # Custom output directory
|
|
1736
|
+
"""
|
|
1737
|
+
import http.server
|
|
1738
|
+
import socketserver
|
|
1739
|
+
import webbrowser
|
|
1740
|
+
|
|
1741
|
+
from grai.core.exporter import export_to_json
|
|
1742
|
+
|
|
1743
|
+
console.print("\n[bold cyan]📚 Generating Knowledge Graph Documentation[/bold cyan]\n")
|
|
1744
|
+
|
|
1745
|
+
try:
|
|
1746
|
+
# Load project
|
|
1747
|
+
project = load_project(project_dir)
|
|
1748
|
+
console.print(f"[green]✓[/green] Loaded project: [cyan]{project.name}[/cyan]")
|
|
1749
|
+
console.print(f" - {len(project.entities)} entities")
|
|
1750
|
+
console.print(f" - {len(project.relations)} relations")
|
|
1751
|
+
|
|
1752
|
+
# Create output directory
|
|
1753
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1754
|
+
|
|
1755
|
+
# Export project data as JSON
|
|
1756
|
+
console.print("\n[cyan]→[/cyan] Exporting project data...")
|
|
1757
|
+
ir_data = export_to_json(project, pretty=False)
|
|
1758
|
+
|
|
1759
|
+
# Generate main documentation HTML
|
|
1760
|
+
console.print("[cyan]→[/cyan] Generating documentation pages...")
|
|
1761
|
+
|
|
1762
|
+
# Create index.html
|
|
1763
|
+
index_html = _generate_docs_index_html(project, ir_data)
|
|
1764
|
+
index_path = output_dir / "index.html"
|
|
1765
|
+
index_path.write_text(index_html)
|
|
1766
|
+
console.print("[green]✓[/green] Created index.html")
|
|
1767
|
+
|
|
1768
|
+
# Create entity catalog page
|
|
1769
|
+
entities_html = _generate_entity_catalog_html(project)
|
|
1770
|
+
entities_path = output_dir / "entities.html"
|
|
1771
|
+
entities_path.write_text(entities_html)
|
|
1772
|
+
console.print("[green]✓[/green] Created entities.html")
|
|
1773
|
+
|
|
1774
|
+
# Create relation catalog page
|
|
1775
|
+
relations_html = _generate_relation_catalog_html(project)
|
|
1776
|
+
relations_path = output_dir / "relations.html"
|
|
1777
|
+
relations_path.write_text(relations_html)
|
|
1778
|
+
console.print("[green]✓[/green] Created relations.html")
|
|
1779
|
+
|
|
1780
|
+
# Create graph visualization page
|
|
1781
|
+
from grai.core.visualizer import generate_d3_visualization
|
|
1782
|
+
|
|
1783
|
+
viz_path = output_dir / "graph.html"
|
|
1784
|
+
generate_d3_visualization(
|
|
1785
|
+
project=project,
|
|
1786
|
+
output_path=viz_path,
|
|
1787
|
+
title=f"{project.name} - Graph Visualization",
|
|
1788
|
+
width=1400,
|
|
1789
|
+
height=900,
|
|
1790
|
+
)
|
|
1791
|
+
console.print("[green]✓[/green] Created graph.html")
|
|
1792
|
+
|
|
1793
|
+
# Create lineage page
|
|
1794
|
+
from grai.core.lineage import build_lineage_graph, visualize_lineage_mermaid
|
|
1795
|
+
|
|
1796
|
+
lineage_graph = build_lineage_graph(project)
|
|
1797
|
+
mermaid_diagram = visualize_lineage_mermaid(lineage_graph)
|
|
1798
|
+
lineage_html = _generate_lineage_html(project, mermaid_diagram)
|
|
1799
|
+
lineage_path = output_dir / "lineage.html"
|
|
1800
|
+
lineage_path.write_text(lineage_html)
|
|
1801
|
+
console.print("[green]✓[/green] Created lineage.html")
|
|
1802
|
+
|
|
1803
|
+
console.print(
|
|
1804
|
+
f"\n[green]✓[/green] Documentation generated in: [cyan]{output_dir.absolute()}[/cyan]"
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
# Serve documentation if requested
|
|
1808
|
+
if serve:
|
|
1809
|
+
console.print("\n[bold cyan]🌐 Starting documentation server...[/bold cyan]\n")
|
|
1810
|
+
|
|
1811
|
+
# Change to docs directory
|
|
1812
|
+
import os
|
|
1813
|
+
|
|
1814
|
+
os.chdir(output_dir.absolute())
|
|
1815
|
+
|
|
1816
|
+
# Create server with address reuse enabled
|
|
1817
|
+
handler = http.server.SimpleHTTPRequestHandler # noqa: N806
|
|
1818
|
+
|
|
1819
|
+
# Enable address reuse to avoid "Address already in use" errors
|
|
1820
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
1821
|
+
|
|
1822
|
+
try:
|
|
1823
|
+
with socketserver.TCPServer(("", port), handler) as httpd:
|
|
1824
|
+
console.print(
|
|
1825
|
+
f"[green]✓[/green] Server running at: [cyan]http://localhost:{port}[/cyan]"
|
|
1826
|
+
)
|
|
1827
|
+
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
|
1828
|
+
|
|
1829
|
+
# Open browser
|
|
1830
|
+
if open_browser:
|
|
1831
|
+
console.print("[cyan]→[/cyan] Opening in browser...")
|
|
1832
|
+
webbrowser.open(f"http://localhost:{port}")
|
|
1833
|
+
|
|
1834
|
+
# Serve forever
|
|
1835
|
+
try:
|
|
1836
|
+
httpd.serve_forever()
|
|
1837
|
+
except KeyboardInterrupt:
|
|
1838
|
+
console.print("\n\n[yellow]Stopping server...[/yellow]")
|
|
1839
|
+
|
|
1840
|
+
except KeyboardInterrupt:
|
|
1841
|
+
# Catch any interrupt that happens during setup
|
|
1842
|
+
console.print("\n\n[yellow]Server stopped[/yellow]")
|
|
1843
|
+
except OSError as e:
|
|
1844
|
+
if "Address already in use" in str(e):
|
|
1845
|
+
console.print(f"[red]✗ Port {port} is already in use[/red]")
|
|
1846
|
+
console.print(
|
|
1847
|
+
f"[yellow]Try a different port: grai docs --serve --port {port + 1}[/yellow]"
|
|
1848
|
+
)
|
|
1849
|
+
else:
|
|
1850
|
+
raise
|
|
1851
|
+
else:
|
|
1852
|
+
console.print("\n[bold]💡 To view documentation:[/bold]")
|
|
1853
|
+
console.print(f" Open: [cyan]file://{index_path.absolute()}[/cyan]")
|
|
1854
|
+
console.print(" Or run: [cyan]grai docs --serve[/cyan]")
|
|
1855
|
+
|
|
1856
|
+
except FileNotFoundError as e:
|
|
1857
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1858
|
+
console.print("[yellow]Hint: Run 'grai init' to create a new project[/yellow]")
|
|
1859
|
+
raise typer.Exit(code=1)
|
|
1860
|
+
except Exception as e:
|
|
1861
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
1862
|
+
import traceback
|
|
1863
|
+
|
|
1864
|
+
traceback.print_exc()
|
|
1865
|
+
raise typer.Exit(code=1)
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _generate_docs_index_html(project: Project, ir_data: str) -> str:
|
|
1869
|
+
"""Generate the main documentation index page."""
|
|
1870
|
+
return f"""<!DOCTYPE html>
|
|
1871
|
+
<html lang="en">
|
|
1872
|
+
<head>
|
|
1873
|
+
<meta charset="UTF-8">
|
|
1874
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1875
|
+
<title>{project.name} - Knowledge Graph Documentation</title>
|
|
1876
|
+
<style>
|
|
1877
|
+
* {{
|
|
1878
|
+
margin: 0;
|
|
1879
|
+
padding: 0;
|
|
1880
|
+
box-sizing: border-box;
|
|
1881
|
+
}}
|
|
1882
|
+
|
|
1883
|
+
body {{
|
|
1884
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
1885
|
+
line-height: 1.6;
|
|
1886
|
+
color: #333;
|
|
1887
|
+
background: #f5f5f5;
|
|
1888
|
+
}}
|
|
1889
|
+
|
|
1890
|
+
.header {{
|
|
1891
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
1892
|
+
color: white;
|
|
1893
|
+
padding: 2rem;
|
|
1894
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
1895
|
+
}}
|
|
1896
|
+
|
|
1897
|
+
.header h1 {{
|
|
1898
|
+
font-size: 2.5rem;
|
|
1899
|
+
margin-bottom: 0.5rem;
|
|
1900
|
+
}}
|
|
1901
|
+
|
|
1902
|
+
.header p {{
|
|
1903
|
+
opacity: 0.9;
|
|
1904
|
+
font-size: 1.1rem;
|
|
1905
|
+
}}
|
|
1906
|
+
|
|
1907
|
+
nav {{
|
|
1908
|
+
background: white;
|
|
1909
|
+
padding: 1rem 2rem;
|
|
1910
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
1911
|
+
position: sticky;
|
|
1912
|
+
top: 0;
|
|
1913
|
+
z-index: 100;
|
|
1914
|
+
}}
|
|
1915
|
+
|
|
1916
|
+
nav a {{
|
|
1917
|
+
color: #667eea;
|
|
1918
|
+
text-decoration: none;
|
|
1919
|
+
margin-right: 2rem;
|
|
1920
|
+
font-weight: 500;
|
|
1921
|
+
transition: color 0.2s;
|
|
1922
|
+
}}
|
|
1923
|
+
|
|
1924
|
+
nav a:hover {{
|
|
1925
|
+
color: #764ba2;
|
|
1926
|
+
}}
|
|
1927
|
+
|
|
1928
|
+
.container {{
|
|
1929
|
+
max-width: 1200px;
|
|
1930
|
+
margin: 0 auto;
|
|
1931
|
+
padding: 2rem;
|
|
1932
|
+
}}
|
|
1933
|
+
|
|
1934
|
+
.card-grid {{
|
|
1935
|
+
display: grid;
|
|
1936
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
1937
|
+
gap: 1.5rem;
|
|
1938
|
+
margin: 2rem 0;
|
|
1939
|
+
}}
|
|
1940
|
+
|
|
1941
|
+
.card {{
|
|
1942
|
+
background: white;
|
|
1943
|
+
border-radius: 8px;
|
|
1944
|
+
padding: 2rem;
|
|
1945
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
1946
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
1947
|
+
}}
|
|
1948
|
+
|
|
1949
|
+
.card:hover {{
|
|
1950
|
+
transform: translateY(-4px);
|
|
1951
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
1952
|
+
}}
|
|
1953
|
+
|
|
1954
|
+
.card h3 {{
|
|
1955
|
+
color: #667eea;
|
|
1956
|
+
margin-bottom: 0.5rem;
|
|
1957
|
+
font-size: 1.3rem;
|
|
1958
|
+
}}
|
|
1959
|
+
|
|
1960
|
+
.card p {{
|
|
1961
|
+
color: #666;
|
|
1962
|
+
margin-bottom: 1rem;
|
|
1963
|
+
}}
|
|
1964
|
+
|
|
1965
|
+
.card a {{
|
|
1966
|
+
color: #667eea;
|
|
1967
|
+
text-decoration: none;
|
|
1968
|
+
font-weight: 500;
|
|
1969
|
+
}}
|
|
1970
|
+
|
|
1971
|
+
.stats {{
|
|
1972
|
+
display: grid;
|
|
1973
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
1974
|
+
gap: 1rem;
|
|
1975
|
+
margin: 2rem 0;
|
|
1976
|
+
}}
|
|
1977
|
+
|
|
1978
|
+
.stat {{
|
|
1979
|
+
background: white;
|
|
1980
|
+
padding: 1.5rem;
|
|
1981
|
+
border-radius: 8px;
|
|
1982
|
+
text-align: center;
|
|
1983
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
1984
|
+
}}
|
|
1985
|
+
|
|
1986
|
+
.stat-value {{
|
|
1987
|
+
font-size: 2.5rem;
|
|
1988
|
+
font-weight: bold;
|
|
1989
|
+
color: #667eea;
|
|
1990
|
+
}}
|
|
1991
|
+
|
|
1992
|
+
.stat-label {{
|
|
1993
|
+
color: #666;
|
|
1994
|
+
margin-top: 0.5rem;
|
|
1995
|
+
}}
|
|
1996
|
+
|
|
1997
|
+
footer {{
|
|
1998
|
+
text-align: center;
|
|
1999
|
+
padding: 2rem;
|
|
2000
|
+
color: #999;
|
|
2001
|
+
margin-top: 4rem;
|
|
2002
|
+
}}
|
|
2003
|
+
</style>
|
|
2004
|
+
</head>
|
|
2005
|
+
<body>
|
|
2006
|
+
<div class="header">
|
|
2007
|
+
<h1>📊 {project.name}</h1>
|
|
2008
|
+
<p>{project.description if hasattr(project, 'description') and project.description else 'Knowledge Graph Documentation'}</p>
|
|
2009
|
+
<p style="opacity: 0.7; font-size: 0.9rem; margin-top: 0.5rem;">Version {project.version}</p>
|
|
2010
|
+
</div>
|
|
2011
|
+
|
|
2012
|
+
<nav>
|
|
2013
|
+
<a href="index.html">Home</a>
|
|
2014
|
+
<a href="entities.html">Entities</a>
|
|
2015
|
+
<a href="relations.html">Relations</a>
|
|
2016
|
+
<a href="graph.html">Graph Visualization</a>
|
|
2017
|
+
<a href="lineage.html">Lineage</a>
|
|
2018
|
+
</nav>
|
|
2019
|
+
|
|
2020
|
+
<div class="container">
|
|
2021
|
+
<h2>Project Overview</h2>
|
|
2022
|
+
|
|
2023
|
+
<div class="stats">
|
|
2024
|
+
<div class="stat">
|
|
2025
|
+
<div class="stat-value">{len(project.entities)}</div>
|
|
2026
|
+
<div class="stat-label">Entities</div>
|
|
2027
|
+
</div>
|
|
2028
|
+
<div class="stat">
|
|
2029
|
+
<div class="stat-value">{len(project.relations)}</div>
|
|
2030
|
+
<div class="stat-label">Relations</div>
|
|
2031
|
+
</div>
|
|
2032
|
+
<div class="stat">
|
|
2033
|
+
<div class="stat-value">{sum(len(e.properties) for e in project.entities)}</div>
|
|
2034
|
+
<div class="stat-label">Entity Properties</div>
|
|
2035
|
+
</div>
|
|
2036
|
+
<div class="stat">
|
|
2037
|
+
<div class="stat-value">{sum(len(r.properties) for r in project.relations)}</div>
|
|
2038
|
+
<div class="stat-label">Relation Properties</div>
|
|
2039
|
+
</div>
|
|
2040
|
+
</div>
|
|
2041
|
+
|
|
2042
|
+
<h2>Documentation Sections</h2>
|
|
2043
|
+
|
|
2044
|
+
<div class="card-grid">
|
|
2045
|
+
<div class="card">
|
|
2046
|
+
<h3>📦 Entities</h3>
|
|
2047
|
+
<p>Browse all entities in your knowledge graph, including their properties, keys, and source definitions.</p>
|
|
2048
|
+
<a href="entities.html">View Entities →</a>
|
|
2049
|
+
</div>
|
|
2050
|
+
|
|
2051
|
+
<div class="card">
|
|
2052
|
+
<h3>🔗 Relations</h3>
|
|
2053
|
+
<p>Explore relationships between entities, their mappings, and additional properties.</p>
|
|
2054
|
+
<a href="relations.html">View Relations →</a>
|
|
2055
|
+
</div>
|
|
2056
|
+
|
|
2057
|
+
<div class="card">
|
|
2058
|
+
<h3>🕸️ Graph Visualization</h3>
|
|
2059
|
+
<p>Interactive visualization of your entire knowledge graph showing entities and their connections.</p>
|
|
2060
|
+
<a href="graph.html">View Graph →</a>
|
|
2061
|
+
</div>
|
|
2062
|
+
|
|
2063
|
+
<div class="card">
|
|
2064
|
+
<h3>🔄 Lineage</h3>
|
|
2065
|
+
<p>Visualize data lineage and dependencies between entities, relations, and source systems.</p>
|
|
2066
|
+
<a href="lineage.html">View Lineage →</a>
|
|
2067
|
+
</div>
|
|
2068
|
+
</div>
|
|
2069
|
+
</div>
|
|
2070
|
+
|
|
2071
|
+
<footer>
|
|
2072
|
+
<p>Generated by <strong>grai.build</strong> - Declarative Knowledge Graph Modeling</p>
|
|
2073
|
+
</footer>
|
|
2074
|
+
</body>
|
|
2075
|
+
</html>
|
|
2076
|
+
"""
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def _generate_entity_catalog_html(project: Project) -> str:
|
|
2080
|
+
"""Generate entity catalog HTML page."""
|
|
2081
|
+
entities_html = ""
|
|
2082
|
+
for entity in sorted(project.entities, key=lambda e: e.entity):
|
|
2083
|
+
props_html = "".join(
|
|
2084
|
+
[
|
|
2085
|
+
f"<tr><td><code>{p.name}</code></td><td>{p.type.value}</td><td>{'✓' if getattr(p, 'required', False) else ''}</td><td>{getattr(p, 'description', '')}</td></tr>"
|
|
2086
|
+
for p in entity.properties
|
|
2087
|
+
]
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
# Build source info with enhanced details if available
|
|
2091
|
+
source_config = entity.get_source_config()
|
|
2092
|
+
source_html = f"<div><strong>Source:</strong> <code>{source_config.name}</code>"
|
|
2093
|
+
if source_config.type:
|
|
2094
|
+
source_html += f" <span style='color: #667eea; font-size: 0.9em;'>({source_config.type.value})</span>"
|
|
2095
|
+
source_html += "</div>"
|
|
2096
|
+
|
|
2097
|
+
# Add additional source metadata if present
|
|
2098
|
+
source_meta_items = []
|
|
2099
|
+
if source_config.database:
|
|
2100
|
+
source_meta_items.append(
|
|
2101
|
+
f"<div><strong>Database:</strong> <code>{source_config.database}</code></div>"
|
|
2102
|
+
)
|
|
2103
|
+
if source_config.db_schema:
|
|
2104
|
+
source_meta_items.append(
|
|
2105
|
+
f"<div><strong>Schema:</strong> <code>{source_config.db_schema}</code></div>"
|
|
2106
|
+
)
|
|
2107
|
+
if source_config.connection:
|
|
2108
|
+
source_meta_items.append(
|
|
2109
|
+
f"<div><strong>Connection:</strong> <code>{source_config.connection}</code></div>"
|
|
2110
|
+
)
|
|
2111
|
+
source_meta_html = "".join(source_meta_items)
|
|
2112
|
+
|
|
2113
|
+
entities_html += f"""
|
|
2114
|
+
<div class="entity-card">
|
|
2115
|
+
<h3>🔹 {entity.entity}</h3>
|
|
2116
|
+
<div class="meta">
|
|
2117
|
+
{source_html}
|
|
2118
|
+
<div><strong>Keys:</strong> <code>{', '.join(entity.keys)}</code></div>
|
|
2119
|
+
{source_meta_html}
|
|
2120
|
+
</div>
|
|
2121
|
+
{f'<p class="description">{entity.description}</p>' if hasattr(entity, 'description') and entity.description else ''}
|
|
2122
|
+
<h4>Properties ({len(entity.properties)})</h4>
|
|
2123
|
+
<table>
|
|
2124
|
+
<thead>
|
|
2125
|
+
<tr>
|
|
2126
|
+
<th>Name</th>
|
|
2127
|
+
<th>Type</th>
|
|
2128
|
+
<th>Required</th>
|
|
2129
|
+
<th>Description</th>
|
|
2130
|
+
</tr>
|
|
2131
|
+
</thead>
|
|
2132
|
+
<tbody>
|
|
2133
|
+
{props_html if props_html else '<tr><td colspan="4"><em>No properties defined</em></td></tr>'}
|
|
2134
|
+
</tbody>
|
|
2135
|
+
</table>
|
|
2136
|
+
</div>
|
|
2137
|
+
"""
|
|
2138
|
+
|
|
2139
|
+
return f"""<!DOCTYPE html>
|
|
2140
|
+
<html lang="en">
|
|
2141
|
+
<head>
|
|
2142
|
+
<meta charset="UTF-8">
|
|
2143
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2144
|
+
<title>Entities - {project.name}</title>
|
|
2145
|
+
<style>
|
|
2146
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
2147
|
+
body {{
|
|
2148
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
2149
|
+
line-height: 1.6;
|
|
2150
|
+
color: #333;
|
|
2151
|
+
background: #f5f5f5;
|
|
2152
|
+
}}
|
|
2153
|
+
.header {{
|
|
2154
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2155
|
+
color: white;
|
|
2156
|
+
padding: 2rem;
|
|
2157
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
2158
|
+
}}
|
|
2159
|
+
nav {{
|
|
2160
|
+
background: white;
|
|
2161
|
+
padding: 1rem 2rem;
|
|
2162
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
2163
|
+
position: sticky;
|
|
2164
|
+
top: 0;
|
|
2165
|
+
z-index: 100;
|
|
2166
|
+
}}
|
|
2167
|
+
nav a {{
|
|
2168
|
+
color: #667eea;
|
|
2169
|
+
text-decoration: none;
|
|
2170
|
+
margin-right: 2rem;
|
|
2171
|
+
font-weight: 500;
|
|
2172
|
+
}}
|
|
2173
|
+
.container {{
|
|
2174
|
+
max-width: 1200px;
|
|
2175
|
+
margin: 0 auto;
|
|
2176
|
+
padding: 2rem;
|
|
2177
|
+
}}
|
|
2178
|
+
.entity-card {{
|
|
2179
|
+
background: white;
|
|
2180
|
+
border-radius: 8px;
|
|
2181
|
+
padding: 2rem;
|
|
2182
|
+
margin-bottom: 2rem;
|
|
2183
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
2184
|
+
}}
|
|
2185
|
+
.entity-card h3 {{
|
|
2186
|
+
color: #667eea;
|
|
2187
|
+
margin-bottom: 1rem;
|
|
2188
|
+
font-size: 1.5rem;
|
|
2189
|
+
}}
|
|
2190
|
+
.meta {{
|
|
2191
|
+
display: grid;
|
|
2192
|
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
2193
|
+
gap: 1rem;
|
|
2194
|
+
margin-bottom: 1rem;
|
|
2195
|
+
padding: 1rem;
|
|
2196
|
+
background: #f8f9fa;
|
|
2197
|
+
border-radius: 4px;
|
|
2198
|
+
}}
|
|
2199
|
+
.description {{
|
|
2200
|
+
padding: 1rem;
|
|
2201
|
+
background: #f0f4ff;
|
|
2202
|
+
border-left: 4px solid #667eea;
|
|
2203
|
+
margin: 1rem 0;
|
|
2204
|
+
}}
|
|
2205
|
+
table {{
|
|
2206
|
+
width: 100%;
|
|
2207
|
+
border-collapse: collapse;
|
|
2208
|
+
margin-top: 1rem;
|
|
2209
|
+
}}
|
|
2210
|
+
th, td {{
|
|
2211
|
+
text-align: left;
|
|
2212
|
+
padding: 0.75rem;
|
|
2213
|
+
border-bottom: 1px solid #e0e0e0;
|
|
2214
|
+
}}
|
|
2215
|
+
th {{
|
|
2216
|
+
background: #f8f9fa;
|
|
2217
|
+
font-weight: 600;
|
|
2218
|
+
color: #555;
|
|
2219
|
+
}}
|
|
2220
|
+
code {{
|
|
2221
|
+
background: #f5f5f5;
|
|
2222
|
+
padding: 0.2rem 0.4rem;
|
|
2223
|
+
border-radius: 3px;
|
|
2224
|
+
font-family: 'Courier New', monospace;
|
|
2225
|
+
font-size: 0.9em;
|
|
2226
|
+
}}
|
|
2227
|
+
</style>
|
|
2228
|
+
</head>
|
|
2229
|
+
<body>
|
|
2230
|
+
<div class="header">
|
|
2231
|
+
<h1>📦 Entities</h1>
|
|
2232
|
+
<p>{project.name} - Entity Catalog</p>
|
|
2233
|
+
</div>
|
|
2234
|
+
|
|
2235
|
+
<nav>
|
|
2236
|
+
<a href="index.html">Home</a>
|
|
2237
|
+
<a href="entities.html">Entities</a>
|
|
2238
|
+
<a href="relations.html">Relations</a>
|
|
2239
|
+
<a href="graph.html">Graph Visualization</a>
|
|
2240
|
+
<a href="lineage.html">Lineage</a>
|
|
2241
|
+
</nav>
|
|
2242
|
+
|
|
2243
|
+
<div class="container">
|
|
2244
|
+
<p style="margin-bottom: 2rem;">
|
|
2245
|
+
This page lists all entities in your knowledge graph. Each entity represents a node type with defined properties and keys.
|
|
2246
|
+
</p>
|
|
2247
|
+
{entities_html}
|
|
2248
|
+
</div>
|
|
2249
|
+
</body>
|
|
2250
|
+
</html>
|
|
2251
|
+
"""
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _generate_relation_catalog_html(project: Project) -> str:
|
|
2255
|
+
"""Generate relation catalog HTML page."""
|
|
2256
|
+
relations_html = ""
|
|
2257
|
+
for relation in sorted(project.relations, key=lambda r: r.relation):
|
|
2258
|
+
props_html = "".join(
|
|
2259
|
+
[
|
|
2260
|
+
f"<tr><td><code>{p.name}</code></td><td>{p.type.value}</td><td>{'✓' if getattr(p, 'required', False) else ''}</td><td>{getattr(p, 'description', '')}</td></tr>"
|
|
2261
|
+
for p in relation.properties
|
|
2262
|
+
]
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
# Build source info with enhanced details if available
|
|
2266
|
+
source_config = relation.get_source_config()
|
|
2267
|
+
source_html = f"<div><strong>Source:</strong> <code>{source_config.name}</code>"
|
|
2268
|
+
if source_config.type:
|
|
2269
|
+
source_html += f" <span style='color: #667eea; font-size: 0.9em;'>({source_config.type.value})</span>"
|
|
2270
|
+
source_html += "</div>"
|
|
2271
|
+
|
|
2272
|
+
# Add additional source metadata if present
|
|
2273
|
+
source_meta_items = []
|
|
2274
|
+
if source_config.database:
|
|
2275
|
+
source_meta_items.append(
|
|
2276
|
+
f"<div><strong>Database:</strong> <code>{source_config.database}</code></div>"
|
|
2277
|
+
)
|
|
2278
|
+
if source_config.db_schema:
|
|
2279
|
+
source_meta_items.append(
|
|
2280
|
+
f"<div><strong>Schema:</strong> <code>{source_config.db_schema}</code></div>"
|
|
2281
|
+
)
|
|
2282
|
+
if source_config.connection:
|
|
2283
|
+
source_meta_items.append(
|
|
2284
|
+
f"<div><strong>Connection:</strong> <code>{source_config.connection}</code></div>"
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
relations_html += f"""
|
|
2288
|
+
<div class="relation-card">
|
|
2289
|
+
<h3>🔗 {relation.relation}</h3>
|
|
2290
|
+
<div class="mapping">
|
|
2291
|
+
<span class="entity">{relation.from_entity}</span>
|
|
2292
|
+
<span class="arrow">→</span>
|
|
2293
|
+
<span class="entity">{relation.to_entity}</span>
|
|
2294
|
+
</div>
|
|
2295
|
+
<div class="meta">
|
|
2296
|
+
{source_html}
|
|
2297
|
+
<div><strong>From Key:</strong> <code>{relation.mappings.from_key}</code></div>
|
|
2298
|
+
<div><strong>To Key:</strong> <code>{relation.mappings.to_key}</code></div>
|
|
2299
|
+
{("".join(source_meta_items))}
|
|
2300
|
+
</div>
|
|
2301
|
+
{f'<p class="description">{relation.description}</p>' if hasattr(relation, 'description') and relation.description else ''}
|
|
2302
|
+
<h4>Properties ({len(relation.properties)})</h4>
|
|
2303
|
+
<table>
|
|
2304
|
+
<thead>
|
|
2305
|
+
<tr>
|
|
2306
|
+
<th>Name</th>
|
|
2307
|
+
<th>Type</th>
|
|
2308
|
+
<th>Required</th>
|
|
2309
|
+
<th>Description</th>
|
|
2310
|
+
</tr>
|
|
2311
|
+
</thead>
|
|
2312
|
+
<tbody>
|
|
2313
|
+
{props_html if props_html else '<tr><td colspan="4"><em>No properties defined</em></td></tr>'}
|
|
2314
|
+
</tbody>
|
|
2315
|
+
</table>
|
|
2316
|
+
</div>
|
|
2317
|
+
"""
|
|
2318
|
+
|
|
2319
|
+
return f"""<!DOCTYPE html>
|
|
2320
|
+
<html lang="en">
|
|
2321
|
+
<head>
|
|
2322
|
+
<meta charset="UTF-8">
|
|
2323
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2324
|
+
<title>Relations - {project.name}</title>
|
|
2325
|
+
<style>
|
|
2326
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
2327
|
+
body {{
|
|
2328
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
2329
|
+
line-height: 1.6;
|
|
2330
|
+
color: #333;
|
|
2331
|
+
background: #f5f5f5;
|
|
2332
|
+
}}
|
|
2333
|
+
.header {{
|
|
2334
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2335
|
+
color: white;
|
|
2336
|
+
padding: 2rem;
|
|
2337
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
2338
|
+
}}
|
|
2339
|
+
nav {{
|
|
2340
|
+
background: white;
|
|
2341
|
+
padding: 1rem 2rem;
|
|
2342
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
2343
|
+
position: sticky;
|
|
2344
|
+
top: 0;
|
|
2345
|
+
z-index: 100;
|
|
2346
|
+
}}
|
|
2347
|
+
nav a {{
|
|
2348
|
+
color: #667eea;
|
|
2349
|
+
text-decoration: none;
|
|
2350
|
+
margin-right: 2rem;
|
|
2351
|
+
font-weight: 500;
|
|
2352
|
+
}}
|
|
2353
|
+
.container {{
|
|
2354
|
+
max-width: 1200px;
|
|
2355
|
+
margin: 0 auto;
|
|
2356
|
+
padding: 2rem;
|
|
2357
|
+
}}
|
|
2358
|
+
.relation-card {{
|
|
2359
|
+
background: white;
|
|
2360
|
+
border-radius: 8px;
|
|
2361
|
+
padding: 2rem;
|
|
2362
|
+
margin-bottom: 2rem;
|
|
2363
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
2364
|
+
}}
|
|
2365
|
+
.relation-card h3 {{
|
|
2366
|
+
color: #764ba2;
|
|
2367
|
+
margin-bottom: 1rem;
|
|
2368
|
+
font-size: 1.5rem;
|
|
2369
|
+
}}
|
|
2370
|
+
.mapping {{
|
|
2371
|
+
display: flex;
|
|
2372
|
+
align-items: center;
|
|
2373
|
+
gap: 1rem;
|
|
2374
|
+
margin-bottom: 1rem;
|
|
2375
|
+
padding: 1rem;
|
|
2376
|
+
background: #f8f3ff;
|
|
2377
|
+
border-radius: 4px;
|
|
2378
|
+
font-size: 1.1rem;
|
|
2379
|
+
}}
|
|
2380
|
+
.entity {{
|
|
2381
|
+
background: #667eea;
|
|
2382
|
+
color: white;
|
|
2383
|
+
padding: 0.5rem 1rem;
|
|
2384
|
+
border-radius: 4px;
|
|
2385
|
+
font-weight: 500;
|
|
2386
|
+
}}
|
|
2387
|
+
.arrow {{
|
|
2388
|
+
font-size: 1.5rem;
|
|
2389
|
+
color: #764ba2;
|
|
2390
|
+
}}
|
|
2391
|
+
.meta {{
|
|
2392
|
+
display: grid;
|
|
2393
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
2394
|
+
gap: 1rem;
|
|
2395
|
+
margin-bottom: 1rem;
|
|
2396
|
+
padding: 1rem;
|
|
2397
|
+
background: #f8f9fa;
|
|
2398
|
+
border-radius: 4px;
|
|
2399
|
+
}}
|
|
2400
|
+
.description {{
|
|
2401
|
+
padding: 1rem;
|
|
2402
|
+
background: #f0f4ff;
|
|
2403
|
+
border-left: 4px solid #764ba2;
|
|
2404
|
+
margin: 1rem 0;
|
|
2405
|
+
}}
|
|
2406
|
+
table {{
|
|
2407
|
+
width: 100%;
|
|
2408
|
+
border-collapse: collapse;
|
|
2409
|
+
margin-top: 1rem;
|
|
2410
|
+
}}
|
|
2411
|
+
th, td {{
|
|
2412
|
+
text-align: left;
|
|
2413
|
+
padding: 0.75rem;
|
|
2414
|
+
border-bottom: 1px solid #e0e0e0;
|
|
2415
|
+
}}
|
|
2416
|
+
th {{
|
|
2417
|
+
background: #f8f9fa;
|
|
2418
|
+
font-weight: 600;
|
|
2419
|
+
color: #555;
|
|
2420
|
+
}}
|
|
2421
|
+
code {{
|
|
2422
|
+
background: #f5f5f5;
|
|
2423
|
+
padding: 0.2rem 0.4rem;
|
|
2424
|
+
border-radius: 3px;
|
|
2425
|
+
font-family: 'Courier New', monospace;
|
|
2426
|
+
font-size: 0.9em;
|
|
2427
|
+
}}
|
|
2428
|
+
</style>
|
|
2429
|
+
</head>
|
|
2430
|
+
<body>
|
|
2431
|
+
<div class="header">
|
|
2432
|
+
<h1>🔗 Relations</h1>
|
|
2433
|
+
<p>{project.name} - Relation Catalog</p>
|
|
2434
|
+
</div>
|
|
2435
|
+
|
|
2436
|
+
<nav>
|
|
2437
|
+
<a href="index.html">Home</a>
|
|
2438
|
+
<a href="entities.html">Entities</a>
|
|
2439
|
+
<a href="relations.html">Relations</a>
|
|
2440
|
+
<a href="graph.html">Graph Visualization</a>
|
|
2441
|
+
<a href="lineage.html">Lineage</html>
|
|
2442
|
+
</nav>
|
|
2443
|
+
|
|
2444
|
+
<div class="container">
|
|
2445
|
+
<p style="margin-bottom: 2rem;">
|
|
2446
|
+
This page lists all relations in your knowledge graph. Each relation represents an edge type connecting two entity types.
|
|
2447
|
+
</p>
|
|
2448
|
+
{relations_html}
|
|
2449
|
+
</div>
|
|
2450
|
+
</body>
|
|
2451
|
+
</html>
|
|
2452
|
+
"""
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
def _generate_lineage_html(project: Project, mermaid_diagram: str) -> str:
|
|
2456
|
+
"""Generate lineage HTML page with Mermaid diagram."""
|
|
2457
|
+
return f"""<!DOCTYPE html>
|
|
2458
|
+
<html lang="en">
|
|
2459
|
+
<head>
|
|
2460
|
+
<meta charset="UTF-8">
|
|
2461
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2462
|
+
<title>Lineage - {project.name}</title>
|
|
2463
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
2464
|
+
<style>
|
|
2465
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
2466
|
+
body {{
|
|
2467
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
2468
|
+
line-height: 1.6;
|
|
2469
|
+
color: #333;
|
|
2470
|
+
background: #f5f5f5;
|
|
2471
|
+
}}
|
|
2472
|
+
.header {{
|
|
2473
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2474
|
+
color: white;
|
|
2475
|
+
padding: 2rem;
|
|
2476
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
2477
|
+
}}
|
|
2478
|
+
nav {{
|
|
2479
|
+
background: white;
|
|
2480
|
+
padding: 1rem 2rem;
|
|
2481
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
2482
|
+
position: sticky;
|
|
2483
|
+
top: 0;
|
|
2484
|
+
z-index: 100;
|
|
2485
|
+
}}
|
|
2486
|
+
nav a {{
|
|
2487
|
+
color: #667eea;
|
|
2488
|
+
text-decoration: none;
|
|
2489
|
+
margin-right: 2rem;
|
|
2490
|
+
font-weight: 500;
|
|
2491
|
+
}}
|
|
2492
|
+
.container {{
|
|
2493
|
+
max-width: 1400px;
|
|
2494
|
+
margin: 0 auto;
|
|
2495
|
+
padding: 2rem;
|
|
2496
|
+
}}
|
|
2497
|
+
.diagram-container {{
|
|
2498
|
+
background: white;
|
|
2499
|
+
border-radius: 8px;
|
|
2500
|
+
padding: 2rem;
|
|
2501
|
+
margin: 2rem 0;
|
|
2502
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
2503
|
+
overflow-x: auto;
|
|
2504
|
+
}}
|
|
2505
|
+
</style>
|
|
2506
|
+
<script>
|
|
2507
|
+
mermaid.initialize({{ startOnLoad: true, theme: 'default' }});
|
|
2508
|
+
</script>
|
|
2509
|
+
</head>
|
|
2510
|
+
<body>
|
|
2511
|
+
<div class="header">
|
|
2512
|
+
<h1>🔄 Lineage</h1>
|
|
2513
|
+
<p>{project.name} - Data Lineage & Dependencies</p>
|
|
2514
|
+
</div>
|
|
2515
|
+
|
|
2516
|
+
<nav>
|
|
2517
|
+
<a href="index.html">Home</a>
|
|
2518
|
+
<a href="entities.html">Entities</a>
|
|
2519
|
+
<a href="relations.html">Relations</a>
|
|
2520
|
+
<a href="graph.html">Graph Visualization</a>
|
|
2521
|
+
<a href="lineage.html">Lineage</a>
|
|
2522
|
+
</nav>
|
|
2523
|
+
|
|
2524
|
+
<div class="container">
|
|
2525
|
+
<p style="margin-bottom: 2rem;">
|
|
2526
|
+
This diagram shows the data lineage of your knowledge graph, illustrating how source systems flow into entities and how entities connect through relations.
|
|
2527
|
+
</p>
|
|
2528
|
+
|
|
2529
|
+
<div class="diagram-container">
|
|
2530
|
+
<pre class="mermaid">
|
|
2531
|
+
{mermaid_diagram}
|
|
2532
|
+
</pre>
|
|
2533
|
+
</div>
|
|
2534
|
+
</div>
|
|
2535
|
+
</body>
|
|
2536
|
+
</html>
|
|
2537
|
+
"""
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
def main_cli():
|
|
2541
|
+
"""Entry point for the CLI."""
|
|
2542
|
+
app()
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
if __name__ == "__main__":
|
|
2546
|
+
main_cli()
|