Coverage for src/sankey_cashflow/labels.py: 95%
100 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
1from typing import Optional, Union
3import networkx as nx
4from numpy.typing import ArrayLike
6from .utils import is_null, logger
9class RowLabels:
10 """
11 Contains row label data and methods, used to map data categories to source, target, and color information.
12 Accepts an array of dicts on init which can come from a Google sheet or csv.
13 Data looks like: [{'Category Name': 'House', 'Type': 'computed', 'Source': 'Income', 'Target': 'House',
14 'Link color': 'rgba(153, 187, 255, 0.8)', 'Node color': 'rgba(102, 153, 255, 1)', 'Comments': '', '': ''}]
15 Also creates a DAG with source target edges based on labels definitions
16 """
17 def __init__(self, labeldata: list[dict]):
18 self._labeldata = labeldata
19 self._digraph = nx.DiGraph()
20 self._digraph.add_node("Income", ntype="income")
21 self.process_report = f"RowLabel report\n{'=' * 60}\n\n\n"
22 required_columns = ['Category Name', 'Type', 'Source', 'Target', 'Classification', 'Link color', 'Node color']
23 if False in [k in self._labeldata[0].keys() for k in required_columns]:
24 raise Exception(f"Sources-Targets sheet does not have all required columns! Needed: {required_columns}. \
25 Found: {list(self._labeldata[0].keys())}")
26 self._available_attributes = ['source', 'target', 'classification', 'link_color', 'node_color', 'type']
27 # TODO: validation
28 self._lookup = {}
29 # Map out data to lookup dict
30 for i in self._labeldata:
31 if len(i['Category Name']) == 0: # Skip empties
32 self.process_report += f"SKIPPING: {i}\n"
33 continue
34 if i['Category Name'] in self._lookup:
35 raise Exception(f"Duplicate label! {i['Category Name']}")
36 # Add to internal lookup dict
37 self.process_report += f"ADDING LOOKUP: {i}\n"
38 self._lookup[i['Category Name']] = {
39 'source': i.get('Source'),
40 'target': i.get('Target'),
41 'classification': i.get('Classification'),
42 'link_color': i.get('Link color'),
43 'node_color': i.get('Node color'),
44 'type': i.get('Type')
45 }
46 if i.get("Source") == "DEDUCTIONS":
47 # These have to be dynamically generated - skip adding to DAG for now (or maybe entirely)
48 # TODO (maybe): switch to using type and/or DAG attributes
49 self.process_report += f"SKIPPING DEDUCTION: {i}\n"
50 continue
51 if i["Type"] in ['tag', 's-tag']:
52 # NOT adding tag labels to graph - if needed they will be added on the fly
53 # TODO: investigate using DAG attributes instead. Test with distant/complex tag targets.
54 if i["Type"] == 's-tag':
55 self.process_report += f"Adding NODE for s-tag: {i} to DAG\n"
56 self._digraph.add_node(i['Category Name'], type='s-tag')
57 else:
58 self.process_report += f"NOT Adding tag: {i} to DAG\n"
59 continue
60 if i.get('Source') and i.get('Target'): # Add all source/target pairs as edges to DAG
61 # Add to DAG
62 if i.get('Type'):
63 self.process_report += \
64 f"ADDING EDGE: {i.get('Source')} -> {i.get('Target')} (ntype={i.get('Type')})\n"
65 self._digraph.add_edge(i.get('Source'), i.get('Target'), ntype=i.get('Type'))
66 else:
67 self.process_report += f"ADDING EDGE: {i.get('Source')} -> {i.get('Target')}\n"
68 self._digraph.add_edge(i.get('Source'), i.get('Target'))
69 else:
70 self.process_report += f"ERROR (SKIPPING): {i}\n"
71 logger.warning(f"Category: {i['Category Name']} yielded an empty source and/or target! \
72 {i.get('Source')}:{i.get('Target')}")
74 @property
75 def data(self) -> list[dict]:
76 return self._labeldata
78 @property
79 def graph(self) -> nx.DiGraph:
80 return self._digraph
82 def get_longest_path(self) -> ArrayLike:
83 return nx.dag_longest_path(self._digraph)
85 def get_path(self, source: str, target: str) -> Union[list, None]:
86 path = [i for i in nx.all_simple_paths(self._digraph, source, target)]
87 # Note path obj may contain 0 or multiple paths
88 if not path or len(path) == 0:
89 logger.warning(f"No path found for {source}:{target}")
90 elif len(path) > 1:
91 logger.warning(f"Multiple paths found for {source}:{target}. ({path})")
92 else:
93 return path[0]
95 def get_label(self, labelname: str, labeltype: Union[str, None] = None) -> Union[tuple[str, str], None]:
96 """
97 Return a label name & tag pair or None
98 """
99 item = self._lookup.get(labelname) # TODO: test duplicate category names
100 if labeltype == "any":
101 return item
102 if labeltype and labeltype == "tag":
103 if item and item.get('type') == 'tag':
104 return item
105 return None
106 elif labeltype and labeltype == "s-tag":
107 if item and item.get('type') == 's-tag':
108 return item
109 return None
110 if item and item.get('type') not in ['tag', 's-tag']:
111 return item
112 return None
114 def get_attribute(self,
115 labelname: str,
116 labelattribute: str,
117 use_default: Optional[bool] = True,
118 labeltype: Optional[Union[str, None]] = None,
119 original_category: Optional[Union[str, None]] = None) -> Union[str, None]:
120 """
121 Get named attribute by label. Cases:
122 Unknown attribute: raise exception
123 label & labeltype are exist:
124 attribute exists: return attribute
125 attribute doesn't exist: return default attribute
126 label & labeltype doesn't exists:
127 return default attributes
128 """
129 if labelattribute not in self._available_attributes:
130 raise Exception(f"Unknown attribute: {labelattribute}")
131 if labelattribute == "type":
132 item = self.get_label(labelname, "any")
133 else:
134 item = self.get_label(labelname, labeltype)
135 default_item = self.get_label('default')
136 if item:
137 if labelattribute == "type":
138 return item.get("type", "")
139 if not is_null(item[labelattribute]) and len(item[labelattribute]) != 0:
140 return item[labelattribute]
141 elif labelattribute == "type":
142 return None
143 if use_default:
144 # Did not find the attribute - use defaults
145 if labelattribute == "source":
146 return "Uncategorized"
147 if labelattribute == "target":
148 return labelname
149 if labelattribute == "classification":
150 return 'Uncategorized'
151 if labelattribute in ["node_color", "link_color"]:
152 return default_item[labelattribute]
153 return None
155 def print_graph(self, filename: str) -> None:
156 from matplotlib import pyplot as plt
157 plt.tight_layout()
158 nx.draw_networkx(self._digraph, arrows=True)
159 # plt.figure(figsize=(20,20))
160 plt.savefig(filename, dpi=200, format="PNG")
161 plt.clf()