""" Generate interactive HTML visualization for all Power BI Semantic Models Scans the repository for .SemanticModel folders, parses TMDL relationship files, and creates an interactive HTML viewer with relationship diagrams. How to run: 1. Place this script anywhere in your repo (root, scripts/, tools/, etc.) 2. Open a terminal and run: python visualize_all_relationships.py # Scans repo for .SemanticModel folders python visualize_all_relationships.py --no-browser # Skip opening browser Quick tip: Type "python " then drag this file into your terminal to paste the full path. Using VS Code with Copilot? Just ask: "Run the visualize_all_relationships.py script" Requirements: Python 3.7+ (no external dependencies) """ import re import json import sys import argparse from pathlib import Path from collections import defaultdict import webbrowser def parse_tmdl_relationships(file_path): """Parse relationships.tmdl file and extract relationship definitions""" try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() except Exception as e: print(f" āœ— Error reading file {file_path}: {e}") return [] if not content.strip(): return [] relationships = [] # Split by relationship blocks blocks = re.split(r'(?:^|\n)relationship\s+[\w-]+\n', content, flags=re.MULTILINE) rel_ids = re.findall(r'(?:^|\n)relationship\s+([\w-]+)\n', content, flags=re.MULTILINE) for i, block in enumerate(blocks[1:], 0): # Skip first empty block rel = {'id': rel_ids[i] if i < len(rel_ids) else f'rel_{i}'} # Extract properties from_col_match = re.search(r'fromColumn:\s*(.+)', block) to_col_match = re.search(r'toColumn:\s*(.+)', block) from_card_match = re.search(r'fromCardinality:\s*(\w+)', block) to_card_match = re.search(r'toCardinality:\s*(\w+)', block) cross_filter_match = re.search(r'crossFilteringBehavior:\s*(\w+)', block) is_active_match = re.search(r'isActive:\s*(\w+)', block) if from_col_match and to_col_match: from_full = from_col_match.group(1).strip() to_full = to_col_match.group(1).strip() # Parse table.column format from_parts = from_full.split('.') to_parts = to_full.split('.') # Handle quoted table names from_table = from_parts[0].strip("'\"") from_column = '.'.join(from_parts[1:]).strip("'\"") if len(from_parts) > 1 else '' to_table = to_parts[0].strip("'\"") to_column = '.'.join(to_parts[1:]).strip("'\"") if len(to_parts) > 1 else '' rel['from_table'] = from_table rel['from_column'] = from_column rel['to_table'] = to_table rel['to_column'] = to_column rel['from_cardinality'] = from_card_match.group(1) if from_card_match else 'many' rel['to_cardinality'] = to_card_match.group(1) if to_card_match else 'one' rel['cross_filtering'] = cross_filter_match.group(1) if cross_filter_match else 'oneDirection' rel['is_active'] = is_active_match.group(1).lower() != 'false' if is_active_match else True relationships.append(rel) return relationships def prepare_model_data(relationships, model_name): """Prepare visualization data for a model""" nodes = {} edges = [] table_stats = defaultdict(lambda: {'incoming': 0, 'outgoing': 0}) for rel in relationships: from_table = rel['from_table'] to_table = rel['to_table'] # Track statistics table_stats[from_table]['outgoing'] += 1 table_stats[to_table]['incoming'] += 1 # Add nodes if not exists if from_table not in nodes: nodes[from_table] = {'id': from_table, 'label': from_table, 'connections': []} if to_table not in nodes: nodes[to_table] = {'id': to_table, 'label': to_table, 'connections': []} # Track connections if to_table not in nodes[from_table]['connections']: nodes[from_table]['connections'].append(to_table) if from_table not in nodes[to_table]['connections']: nodes[to_table]['connections'].append(from_table) # Create edge # Note: In Power BI, filter direction flows FROM dimension (one-side) TO fact (many-side) # So we reverse the arrow direction from the TMDL definition cardinality = f"{rel['from_cardinality'][0].upper()}:{rel['to_cardinality'][0].upper()}" direction = '↔' if rel['cross_filtering'] == 'bothDirections' else '→' active_text = '' if rel['is_active'] else ' (inactive)' # Reverse arrow direction: filter flows from 'to' (dimension) to 'from' (fact) edge = { 'from': to_table, 'to': from_table, 'label': f"{cardinality} {direction}{active_text}", 'title': f"{to_table}.{rel['to_column']} → {from_table}.{rel['from_column']}", 'arrows': 'to' if rel['cross_filtering'] != 'bothDirections' else 'to, from', 'dashes': not rel['is_active'], 'color': '#8E44AD' if rel['cross_filtering'] == 'bothDirections' else ('#999999' if not rel['is_active'] else '#2C3E50'), 'width': 2.5 if rel['cross_filtering'] == 'bothDirections' else (1 if not rel['is_active'] else 2), 'from_column': rel['to_column'], 'to_column': rel['from_column'], 'cardinality': cardinality, 'cross_filtering': rel['cross_filtering'], 'is_active': rel['is_active'] } edges.append(edge) # Classify tables fact_tables = [t for t, stats in table_stats.items() if stats['outgoing'] > stats['incoming']] # Prepare nodes for vis.js vis_nodes = [] for table_name, node_data in nodes.items(): is_fact = table_name in fact_tables vis_node = { 'id': table_name, 'label': table_name, 'title': f"{table_name}\n{'Fact Table' if is_fact else 'Dimension Table'}\nConnections: {len(node_data['connections'])}", 'color': { 'background': '#FF6B6B' if is_fact else '#4ECDC4', 'border': '#C44545' if is_fact else '#3BA39F', 'highlight': { 'background': '#FF8787' if is_fact else '#6FE8DE', 'border': '#A03333' if is_fact else '#2A7A77' } }, 'shape': 'box' if is_fact else 'ellipse', 'font': {'size': 14, 'color': '#000000', 'bold': True}, 'connections': node_data['connections'] } vis_nodes.append(vis_node) return { 'nodes': vis_nodes, 'edges': edges, 'stats': { 'tables': len(nodes), 'relationships': len(relationships), 'facts': len(fact_tables), 'dimensions': len(nodes) - len(fact_tables) } } def create_multi_model_html(models_data, output_path): """Create an interactive HTML with dropdown to select models""" # Create JavaScript object with all models data models_json = json.dumps(models_data) html_content = f""" Power BI Semantic Models - Relationship Diagrams
šŸ“Š 0 Tables
šŸ”— 0 Relationships
šŸ“¦ 0 Fact Tables
šŸ·ļø 0 Dimension Tables
šŸ’” Interactive Controls: Select a model from the dropdown above. Click on any table to highlight its direct relationships. Connected tables remain colored while unrelated tables are greyed out. Click empty space to reset. Use mouse wheel to zoom, drag to pan.

Legend

Fact Table
Dimension Table
One-way
Bidirectional
Inactive
Ɨ

Filter Tables

Ɨ

Table Information

""" try: with open(output_path, 'w', encoding='utf-8') as f: f.write(html_content) print(f"āœ“ Multi-model interactive HTML saved to: {output_path}") except Exception as e: print(f"āœ— Error writing HTML file: {e}") raise def find_relationship_files(search_path: Path) -> list: """Recursively find all relationships.tmdl files under search_path""" print(f"šŸ” Scanning for semantic models in: {search_path}") if not search_path.exists(): print(f"āŒ Error: Search path does not exist: {search_path}") return [] relationship_files = list(search_path.glob("**/*.SemanticModel/definition/relationships.tmdl")) if not relationship_files: print(f"āš ļø No semantic models found with relationships.tmdl files") print(f" Expected structure: .SemanticModel/definition/relationships.tmdl") return relationship_files def main(): parser = argparse.ArgumentParser( description='Generate interactive HTML visualization for Power BI Semantic Model relationships', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s %(prog)s --search-path ./my_models %(prog)s --no-browser """ ) parser.add_argument( '--search-path', type=Path, default=Path.cwd(), help='Root directory to search for .SemanticModel folders (default: scans entire repo from current directory)' ) parser.add_argument( '--no-browser', action='store_true', help='Skip automatically opening the HTML file in browser' ) args = parser.parse_args() # Find all relationships.tmdl files relationship_files = find_relationship_files(args.search_path) if not relationship_files: print("\nšŸ’” No semantic models found in repository.") print(" Make sure your .SemanticModel folders contain definition/relationships.tmdl files.") print(" Or use --search-path to specify a different directory.") return 1 print(f"Found {len(relationship_files)} semantic models") models_data = {} for rel_file in relationship_files: # Extract model name model_name = rel_file.parts[-3].replace('.SemanticModel', '') print(f" Processing: {model_name}") try: relationships = parse_tmdl_relationships(rel_file) if relationships: model_data = prepare_model_data(relationships, model_name) models_data[model_name] = { 'data': model_data, 'stats': model_data['stats'] } print(f" āœ“ {model_data['stats']['relationships']} relationships, {model_data['stats']['tables']} tables") else: print(f" ⚠ No relationships found") except Exception as e: print(f" āœ— Error: {e}") if not models_data: print("\nāš ļø No models with relationships found.") return 1 # Set output path output_path = Path.cwd() / "relationships_viewer.html" print(f"\nšŸŽØ Creating multi-model viewer...") create_multi_model_html(models_data, output_path) print(f"\nāœ… Done! Visualization saved to: {output_path}") print(f" Models available: {len(models_data)}") # Open in default browser unless --no-browser flag is set if not args.no_browser: print(f" Opening in browser...") webbrowser.open(output_path.as_uri()) else: print(f" Open manually: {output_path}") return 0 if __name__ == "__main__": sys.exit(main())