sqlglot.lineage
1from __future__ import annotations 2 3import json 4import logging 5import typing as t 6from dataclasses import dataclass, field 7 8from sqlglot import Schema, exp, maybe_parse 9from sqlglot.errors import SqlglotError 10from sqlglot.optimizer import Scope, build_scope, find_all_in_scope, normalize_identifiers, qualify 11 12if t.TYPE_CHECKING: 13 from sqlglot.dialects.dialect import DialectType 14 15logger = logging.getLogger("sqlglot") 16 17 18@dataclass(frozen=True) 19class Node: 20 name: str 21 expression: exp.Expression 22 source: exp.Expression 23 downstream: t.List[Node] = field(default_factory=list) 24 source_name: str = "" 25 reference_node_name: str = "" 26 27 def walk(self) -> t.Iterator[Node]: 28 yield self 29 30 for d in self.downstream: 31 yield from d.walk() 32 33 def to_html(self, dialect: DialectType = None, **opts) -> GraphHTML: 34 nodes = {} 35 edges = [] 36 37 for node in self.walk(): 38 if isinstance(node.expression, exp.Table): 39 label = f"FROM {node.expression.this}" 40 title = f"<pre>SELECT {node.name} FROM {node.expression.this}</pre>" 41 group = 1 42 else: 43 label = node.expression.sql(pretty=True, dialect=dialect) 44 source = node.source.transform( 45 lambda n: ( 46 exp.Tag(this=n, prefix="<b>", postfix="</b>") if n is node.expression else n 47 ), 48 copy=False, 49 ).sql(pretty=True, dialect=dialect) 50 title = f"<pre>{source}</pre>" 51 group = 0 52 53 node_id = id(node) 54 55 nodes[node_id] = { 56 "id": node_id, 57 "label": label, 58 "title": title, 59 "group": group, 60 } 61 62 for d in node.downstream: 63 edges.append({"from": node_id, "to": id(d)}) 64 return GraphHTML(nodes, edges, **opts) 65 66 67def lineage( 68 column: str | exp.Column, 69 sql: str | exp.Expression, 70 schema: t.Optional[t.Dict | Schema] = None, 71 sources: t.Optional[t.Mapping[str, str | exp.Query]] = None, 72 dialect: DialectType = None, 73 scope: t.Optional[Scope] = None, 74 trim_selects: bool = True, 75 **kwargs, 76) -> Node: 77 """Build the lineage graph for a column of a SQL query. 78 79 Args: 80 column: The column to build the lineage for. 81 sql: The SQL string or expression. 82 schema: The schema of tables. 83 sources: A mapping of queries which will be used to continue building lineage. 84 dialect: The dialect of input SQL. 85 scope: A pre-created scope to use instead. 86 trim_selects: Whether or not to clean up selects by trimming to only relevant columns. 87 **kwargs: Qualification optimizer kwargs. 88 89 Returns: 90 A lineage node. 91 """ 92 93 expression = maybe_parse(sql, dialect=dialect) 94 column = normalize_identifiers.normalize_identifiers(column, dialect=dialect).name 95 96 if sources: 97 expression = exp.expand( 98 expression, 99 {k: t.cast(exp.Query, maybe_parse(v, dialect=dialect)) for k, v in sources.items()}, 100 dialect=dialect, 101 ) 102 103 if not scope: 104 expression = qualify.qualify( 105 expression, 106 dialect=dialect, 107 schema=schema, 108 **{"validate_qualify_columns": False, "identify": False, **kwargs}, # type: ignore 109 ) 110 111 scope = build_scope(expression) 112 113 if not scope: 114 raise SqlglotError("Cannot build lineage, sql must be SELECT") 115 116 if not any(select.alias_or_name == column for select in scope.expression.selects): 117 raise SqlglotError(f"Cannot find column '{column}' in query.") 118 119 return to_node(column, scope, dialect, trim_selects=trim_selects) 120 121 122def to_node( 123 column: str | int, 124 scope: Scope, 125 dialect: DialectType, 126 scope_name: t.Optional[str] = None, 127 upstream: t.Optional[Node] = None, 128 source_name: t.Optional[str] = None, 129 reference_node_name: t.Optional[str] = None, 130 trim_selects: bool = True, 131) -> Node: 132 # Find the specific select clause that is the source of the column we want. 133 # This can either be a specific, named select or a generic `*` clause. 134 select = ( 135 scope.expression.selects[column] 136 if isinstance(column, int) 137 else next( 138 (select for select in scope.expression.selects if select.alias_or_name == column), 139 exp.Star() if scope.expression.is_star else scope.expression, 140 ) 141 ) 142 143 if isinstance(scope.expression, exp.Subquery): 144 for source in scope.subquery_scopes: 145 return to_node( 146 column, 147 scope=source, 148 dialect=dialect, 149 upstream=upstream, 150 source_name=source_name, 151 reference_node_name=reference_node_name, 152 trim_selects=trim_selects, 153 ) 154 if isinstance(scope.expression, exp.Union): 155 upstream = upstream or Node(name="UNION", source=scope.expression, expression=select) 156 157 index = ( 158 column 159 if isinstance(column, int) 160 else next( 161 ( 162 i 163 for i, select in enumerate(scope.expression.selects) 164 if select.alias_or_name == column or select.is_star 165 ), 166 -1, # mypy will not allow a None here, but a negative index should never be returned 167 ) 168 ) 169 170 if index == -1: 171 raise ValueError(f"Could not find {column} in {scope.expression}") 172 173 for s in scope.union_scopes: 174 to_node( 175 index, 176 scope=s, 177 dialect=dialect, 178 upstream=upstream, 179 source_name=source_name, 180 reference_node_name=reference_node_name, 181 trim_selects=trim_selects, 182 ) 183 184 return upstream 185 186 if trim_selects and isinstance(scope.expression, exp.Select): 187 # For better ergonomics in our node labels, replace the full select with 188 # a version that has only the column we care about. 189 # "x", SELECT x, y FROM foo 190 # => "x", SELECT x FROM foo 191 source = t.cast(exp.Expression, scope.expression.select(select, append=False)) 192 else: 193 source = scope.expression 194 195 # Create the node for this step in the lineage chain, and attach it to the previous one. 196 node = Node( 197 name=f"{scope_name}.{column}" if scope_name else str(column), 198 source=source, 199 expression=select, 200 source_name=source_name or "", 201 reference_node_name=reference_node_name or "", 202 ) 203 204 if upstream: 205 upstream.downstream.append(node) 206 207 subquery_scopes = { 208 id(subquery_scope.expression): subquery_scope for subquery_scope in scope.subquery_scopes 209 } 210 211 for subquery in find_all_in_scope(select, exp.UNWRAPPED_QUERIES): 212 subquery_scope = subquery_scopes.get(id(subquery)) 213 if not subquery_scope: 214 logger.warning(f"Unknown subquery scope: {subquery.sql(dialect=dialect)}") 215 continue 216 217 for name in subquery.named_selects: 218 to_node( 219 name, 220 scope=subquery_scope, 221 dialect=dialect, 222 upstream=node, 223 trim_selects=trim_selects, 224 ) 225 226 # if the select is a star add all scope sources as downstreams 227 if select.is_star: 228 for source in scope.sources.values(): 229 if isinstance(source, Scope): 230 source = source.expression 231 node.downstream.append(Node(name=select.sql(), source=source, expression=source)) 232 233 # Find all columns that went into creating this one to list their lineage nodes. 234 source_columns = set(find_all_in_scope(select, exp.Column)) 235 236 # If the source is a UDTF find columns used in the UTDF to generate the table 237 if isinstance(source, exp.UDTF): 238 source_columns |= set(source.find_all(exp.Column)) 239 derived_tables = [ 240 source.expression.parent 241 for source in scope.sources.values() 242 if isinstance(source, Scope) and source.is_derived_table 243 ] 244 else: 245 derived_tables = scope.derived_tables 246 247 source_names = { 248 dt.alias: dt.comments[0].split()[1] 249 for dt in derived_tables 250 if dt.comments and dt.comments[0].startswith("source: ") 251 } 252 253 for c in source_columns: 254 table = c.table 255 source = scope.sources.get(table) 256 257 if isinstance(source, Scope): 258 selected_node, _ = scope.selected_sources.get(table, (None, None)) 259 # The table itself came from a more specific scope. Recurse into that one using the unaliased column name. 260 to_node( 261 c.name, 262 scope=source, 263 dialect=dialect, 264 scope_name=table, 265 upstream=node, 266 source_name=source_names.get(table) or source_name, 267 reference_node_name=selected_node.name if selected_node else None, 268 trim_selects=trim_selects, 269 ) 270 else: 271 # The source is not a scope - we've reached the end of the line. At this point, if a source is not found 272 # it means this column's lineage is unknown. This can happen if the definition of a source used in a query 273 # is not passed into the `sources` map. 274 source = source or exp.Placeholder() 275 node.downstream.append(Node(name=c.sql(), source=source, expression=source)) 276 277 return node 278 279 280class GraphHTML: 281 """Node to HTML generator using vis.js. 282 283 https://visjs.github.io/vis-network/docs/network/ 284 """ 285 286 def __init__( 287 self, nodes: t.Dict, edges: t.List, imports: bool = True, options: t.Optional[t.Dict] = None 288 ): 289 self.imports = imports 290 291 self.options = { 292 "height": "500px", 293 "width": "100%", 294 "layout": { 295 "hierarchical": { 296 "enabled": True, 297 "nodeSpacing": 200, 298 "sortMethod": "directed", 299 }, 300 }, 301 "interaction": { 302 "dragNodes": False, 303 "selectable": False, 304 }, 305 "physics": { 306 "enabled": False, 307 }, 308 "edges": { 309 "arrows": "to", 310 }, 311 "nodes": { 312 "font": "20px monaco", 313 "shape": "box", 314 "widthConstraint": { 315 "maximum": 300, 316 }, 317 }, 318 **(options or {}), 319 } 320 321 self.nodes = nodes 322 self.edges = edges 323 324 def __str__(self): 325 nodes = json.dumps(list(self.nodes.values())) 326 edges = json.dumps(self.edges) 327 options = json.dumps(self.options) 328 imports = ( 329 """<script type="text/javascript" src="https://unpkg.com/vis-data@latest/peer/umd/vis-data.min.js"></script> 330 <script type="text/javascript" src="https://unpkg.com/vis-network@latest/peer/umd/vis-network.min.js"></script> 331 <link rel="stylesheet" type="text/css" href="https://unpkg.com/vis-network/styles/vis-network.min.css" />""" 332 if self.imports 333 else "" 334 ) 335 336 return f"""<div> 337 <div id="sqlglot-lineage"></div> 338 {imports} 339 <script type="text/javascript"> 340 var nodes = new vis.DataSet({nodes}) 341 nodes.forEach(row => row["title"] = new DOMParser().parseFromString(row["title"], "text/html").body.childNodes[0]) 342 343 new vis.Network( 344 document.getElementById("sqlglot-lineage"), 345 {{ 346 nodes: nodes, 347 edges: new vis.DataSet({edges}) 348 }}, 349 {options}, 350 ) 351 </script> 352</div>""" 353 354 def _repr_html_(self) -> str: 355 return self.__str__()
logger =
<Logger sqlglot (WARNING)>
@dataclass(frozen=True)
class
Node:
19@dataclass(frozen=True) 20class Node: 21 name: str 22 expression: exp.Expression 23 source: exp.Expression 24 downstream: t.List[Node] = field(default_factory=list) 25 source_name: str = "" 26 reference_node_name: str = "" 27 28 def walk(self) -> t.Iterator[Node]: 29 yield self 30 31 for d in self.downstream: 32 yield from d.walk() 33 34 def to_html(self, dialect: DialectType = None, **opts) -> GraphHTML: 35 nodes = {} 36 edges = [] 37 38 for node in self.walk(): 39 if isinstance(node.expression, exp.Table): 40 label = f"FROM {node.expression.this}" 41 title = f"<pre>SELECT {node.name} FROM {node.expression.this}</pre>" 42 group = 1 43 else: 44 label = node.expression.sql(pretty=True, dialect=dialect) 45 source = node.source.transform( 46 lambda n: ( 47 exp.Tag(this=n, prefix="<b>", postfix="</b>") if n is node.expression else n 48 ), 49 copy=False, 50 ).sql(pretty=True, dialect=dialect) 51 title = f"<pre>{source}</pre>" 52 group = 0 53 54 node_id = id(node) 55 56 nodes[node_id] = { 57 "id": node_id, 58 "label": label, 59 "title": title, 60 "group": group, 61 } 62 63 for d in node.downstream: 64 edges.append({"from": node_id, "to": id(d)}) 65 return GraphHTML(nodes, edges, **opts)
Node( name: str, expression: sqlglot.expressions.Expression, source: sqlglot.expressions.Expression, downstream: List[Node] = <factory>, source_name: str = '', reference_node_name: str = '')
expression: sqlglot.expressions.Expression
source: sqlglot.expressions.Expression
downstream: List[Node]
def
to_html( self, dialect: Union[str, sqlglot.dialects.dialect.Dialect, Type[sqlglot.dialects.dialect.Dialect], NoneType] = None, **opts) -> GraphHTML:
34 def to_html(self, dialect: DialectType = None, **opts) -> GraphHTML: 35 nodes = {} 36 edges = [] 37 38 for node in self.walk(): 39 if isinstance(node.expression, exp.Table): 40 label = f"FROM {node.expression.this}" 41 title = f"<pre>SELECT {node.name} FROM {node.expression.this}</pre>" 42 group = 1 43 else: 44 label = node.expression.sql(pretty=True, dialect=dialect) 45 source = node.source.transform( 46 lambda n: ( 47 exp.Tag(this=n, prefix="<b>", postfix="</b>") if n is node.expression else n 48 ), 49 copy=False, 50 ).sql(pretty=True, dialect=dialect) 51 title = f"<pre>{source}</pre>" 52 group = 0 53 54 node_id = id(node) 55 56 nodes[node_id] = { 57 "id": node_id, 58 "label": label, 59 "title": title, 60 "group": group, 61 } 62 63 for d in node.downstream: 64 edges.append({"from": node_id, "to": id(d)}) 65 return GraphHTML(nodes, edges, **opts)
def
lineage( column: str | sqlglot.expressions.Column, sql: str | sqlglot.expressions.Expression, schema: Union[Dict, sqlglot.schema.Schema, NoneType] = None, sources: Optional[Mapping[str, str | sqlglot.expressions.Query]] = None, dialect: Union[str, sqlglot.dialects.dialect.Dialect, Type[sqlglot.dialects.dialect.Dialect], NoneType] = None, scope: Optional[sqlglot.optimizer.scope.Scope] = None, trim_selects: bool = True, **kwargs) -> Node:
68def lineage( 69 column: str | exp.Column, 70 sql: str | exp.Expression, 71 schema: t.Optional[t.Dict | Schema] = None, 72 sources: t.Optional[t.Mapping[str, str | exp.Query]] = None, 73 dialect: DialectType = None, 74 scope: t.Optional[Scope] = None, 75 trim_selects: bool = True, 76 **kwargs, 77) -> Node: 78 """Build the lineage graph for a column of a SQL query. 79 80 Args: 81 column: The column to build the lineage for. 82 sql: The SQL string or expression. 83 schema: The schema of tables. 84 sources: A mapping of queries which will be used to continue building lineage. 85 dialect: The dialect of input SQL. 86 scope: A pre-created scope to use instead. 87 trim_selects: Whether or not to clean up selects by trimming to only relevant columns. 88 **kwargs: Qualification optimizer kwargs. 89 90 Returns: 91 A lineage node. 92 """ 93 94 expression = maybe_parse(sql, dialect=dialect) 95 column = normalize_identifiers.normalize_identifiers(column, dialect=dialect).name 96 97 if sources: 98 expression = exp.expand( 99 expression, 100 {k: t.cast(exp.Query, maybe_parse(v, dialect=dialect)) for k, v in sources.items()}, 101 dialect=dialect, 102 ) 103 104 if not scope: 105 expression = qualify.qualify( 106 expression, 107 dialect=dialect, 108 schema=schema, 109 **{"validate_qualify_columns": False, "identify": False, **kwargs}, # type: ignore 110 ) 111 112 scope = build_scope(expression) 113 114 if not scope: 115 raise SqlglotError("Cannot build lineage, sql must be SELECT") 116 117 if not any(select.alias_or_name == column for select in scope.expression.selects): 118 raise SqlglotError(f"Cannot find column '{column}' in query.") 119 120 return to_node(column, scope, dialect, trim_selects=trim_selects)
Build the lineage graph for a column of a SQL query.
Arguments:
- column: The column to build the lineage for.
- sql: The SQL string or expression.
- schema: The schema of tables.
- sources: A mapping of queries which will be used to continue building lineage.
- dialect: The dialect of input SQL.
- scope: A pre-created scope to use instead.
- trim_selects: Whether or not to clean up selects by trimming to only relevant columns.
- **kwargs: Qualification optimizer kwargs.
Returns:
A lineage node.
def
to_node( column: str | int, scope: sqlglot.optimizer.scope.Scope, dialect: Union[str, sqlglot.dialects.dialect.Dialect, Type[sqlglot.dialects.dialect.Dialect], NoneType], scope_name: Optional[str] = None, upstream: Optional[Node] = None, source_name: Optional[str] = None, reference_node_name: Optional[str] = None, trim_selects: bool = True) -> Node:
123def to_node( 124 column: str | int, 125 scope: Scope, 126 dialect: DialectType, 127 scope_name: t.Optional[str] = None, 128 upstream: t.Optional[Node] = None, 129 source_name: t.Optional[str] = None, 130 reference_node_name: t.Optional[str] = None, 131 trim_selects: bool = True, 132) -> Node: 133 # Find the specific select clause that is the source of the column we want. 134 # This can either be a specific, named select or a generic `*` clause. 135 select = ( 136 scope.expression.selects[column] 137 if isinstance(column, int) 138 else next( 139 (select for select in scope.expression.selects if select.alias_or_name == column), 140 exp.Star() if scope.expression.is_star else scope.expression, 141 ) 142 ) 143 144 if isinstance(scope.expression, exp.Subquery): 145 for source in scope.subquery_scopes: 146 return to_node( 147 column, 148 scope=source, 149 dialect=dialect, 150 upstream=upstream, 151 source_name=source_name, 152 reference_node_name=reference_node_name, 153 trim_selects=trim_selects, 154 ) 155 if isinstance(scope.expression, exp.Union): 156 upstream = upstream or Node(name="UNION", source=scope.expression, expression=select) 157 158 index = ( 159 column 160 if isinstance(column, int) 161 else next( 162 ( 163 i 164 for i, select in enumerate(scope.expression.selects) 165 if select.alias_or_name == column or select.is_star 166 ), 167 -1, # mypy will not allow a None here, but a negative index should never be returned 168 ) 169 ) 170 171 if index == -1: 172 raise ValueError(f"Could not find {column} in {scope.expression}") 173 174 for s in scope.union_scopes: 175 to_node( 176 index, 177 scope=s, 178 dialect=dialect, 179 upstream=upstream, 180 source_name=source_name, 181 reference_node_name=reference_node_name, 182 trim_selects=trim_selects, 183 ) 184 185 return upstream 186 187 if trim_selects and isinstance(scope.expression, exp.Select): 188 # For better ergonomics in our node labels, replace the full select with 189 # a version that has only the column we care about. 190 # "x", SELECT x, y FROM foo 191 # => "x", SELECT x FROM foo 192 source = t.cast(exp.Expression, scope.expression.select(select, append=False)) 193 else: 194 source = scope.expression 195 196 # Create the node for this step in the lineage chain, and attach it to the previous one. 197 node = Node( 198 name=f"{scope_name}.{column}" if scope_name else str(column), 199 source=source, 200 expression=select, 201 source_name=source_name or "", 202 reference_node_name=reference_node_name or "", 203 ) 204 205 if upstream: 206 upstream.downstream.append(node) 207 208 subquery_scopes = { 209 id(subquery_scope.expression): subquery_scope for subquery_scope in scope.subquery_scopes 210 } 211 212 for subquery in find_all_in_scope(select, exp.UNWRAPPED_QUERIES): 213 subquery_scope = subquery_scopes.get(id(subquery)) 214 if not subquery_scope: 215 logger.warning(f"Unknown subquery scope: {subquery.sql(dialect=dialect)}") 216 continue 217 218 for name in subquery.named_selects: 219 to_node( 220 name, 221 scope=subquery_scope, 222 dialect=dialect, 223 upstream=node, 224 trim_selects=trim_selects, 225 ) 226 227 # if the select is a star add all scope sources as downstreams 228 if select.is_star: 229 for source in scope.sources.values(): 230 if isinstance(source, Scope): 231 source = source.expression 232 node.downstream.append(Node(name=select.sql(), source=source, expression=source)) 233 234 # Find all columns that went into creating this one to list their lineage nodes. 235 source_columns = set(find_all_in_scope(select, exp.Column)) 236 237 # If the source is a UDTF find columns used in the UTDF to generate the table 238 if isinstance(source, exp.UDTF): 239 source_columns |= set(source.find_all(exp.Column)) 240 derived_tables = [ 241 source.expression.parent 242 for source in scope.sources.values() 243 if isinstance(source, Scope) and source.is_derived_table 244 ] 245 else: 246 derived_tables = scope.derived_tables 247 248 source_names = { 249 dt.alias: dt.comments[0].split()[1] 250 for dt in derived_tables 251 if dt.comments and dt.comments[0].startswith("source: ") 252 } 253 254 for c in source_columns: 255 table = c.table 256 source = scope.sources.get(table) 257 258 if isinstance(source, Scope): 259 selected_node, _ = scope.selected_sources.get(table, (None, None)) 260 # The table itself came from a more specific scope. Recurse into that one using the unaliased column name. 261 to_node( 262 c.name, 263 scope=source, 264 dialect=dialect, 265 scope_name=table, 266 upstream=node, 267 source_name=source_names.get(table) or source_name, 268 reference_node_name=selected_node.name if selected_node else None, 269 trim_selects=trim_selects, 270 ) 271 else: 272 # The source is not a scope - we've reached the end of the line. At this point, if a source is not found 273 # it means this column's lineage is unknown. This can happen if the definition of a source used in a query 274 # is not passed into the `sources` map. 275 source = source or exp.Placeholder() 276 node.downstream.append(Node(name=c.sql(), source=source, expression=source)) 277 278 return node
class
GraphHTML:
281class GraphHTML: 282 """Node to HTML generator using vis.js. 283 284 https://visjs.github.io/vis-network/docs/network/ 285 """ 286 287 def __init__( 288 self, nodes: t.Dict, edges: t.List, imports: bool = True, options: t.Optional[t.Dict] = None 289 ): 290 self.imports = imports 291 292 self.options = { 293 "height": "500px", 294 "width": "100%", 295 "layout": { 296 "hierarchical": { 297 "enabled": True, 298 "nodeSpacing": 200, 299 "sortMethod": "directed", 300 }, 301 }, 302 "interaction": { 303 "dragNodes": False, 304 "selectable": False, 305 }, 306 "physics": { 307 "enabled": False, 308 }, 309 "edges": { 310 "arrows": "to", 311 }, 312 "nodes": { 313 "font": "20px monaco", 314 "shape": "box", 315 "widthConstraint": { 316 "maximum": 300, 317 }, 318 }, 319 **(options or {}), 320 } 321 322 self.nodes = nodes 323 self.edges = edges 324 325 def __str__(self): 326 nodes = json.dumps(list(self.nodes.values())) 327 edges = json.dumps(self.edges) 328 options = json.dumps(self.options) 329 imports = ( 330 """<script type="text/javascript" src="https://unpkg.com/vis-data@latest/peer/umd/vis-data.min.js"></script> 331 <script type="text/javascript" src="https://unpkg.com/vis-network@latest/peer/umd/vis-network.min.js"></script> 332 <link rel="stylesheet" type="text/css" href="https://unpkg.com/vis-network/styles/vis-network.min.css" />""" 333 if self.imports 334 else "" 335 ) 336 337 return f"""<div> 338 <div id="sqlglot-lineage"></div> 339 {imports} 340 <script type="text/javascript"> 341 var nodes = new vis.DataSet({nodes}) 342 nodes.forEach(row => row["title"] = new DOMParser().parseFromString(row["title"], "text/html").body.childNodes[0]) 343 344 new vis.Network( 345 document.getElementById("sqlglot-lineage"), 346 {{ 347 nodes: nodes, 348 edges: new vis.DataSet({edges}) 349 }}, 350 {options}, 351 ) 352 </script> 353</div>""" 354 355 def _repr_html_(self) -> str: 356 return self.__str__()
Node to HTML generator using vis.js.
GraphHTML( nodes: Dict, edges: List, imports: bool = True, options: Optional[Dict] = None)
287 def __init__( 288 self, nodes: t.Dict, edges: t.List, imports: bool = True, options: t.Optional[t.Dict] = None 289 ): 290 self.imports = imports 291 292 self.options = { 293 "height": "500px", 294 "width": "100%", 295 "layout": { 296 "hierarchical": { 297 "enabled": True, 298 "nodeSpacing": 200, 299 "sortMethod": "directed", 300 }, 301 }, 302 "interaction": { 303 "dragNodes": False, 304 "selectable": False, 305 }, 306 "physics": { 307 "enabled": False, 308 }, 309 "edges": { 310 "arrows": "to", 311 }, 312 "nodes": { 313 "font": "20px monaco", 314 "shape": "box", 315 "widthConstraint": { 316 "maximum": 300, 317 }, 318 }, 319 **(options or {}), 320 } 321 322 self.nodes = nodes 323 self.edges = edges