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/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()