44Draw graph in HTML for a specific metric.
55"""
66
7+ import sys
78from pathlib import Path
89
910import plotly .graph_objs as go
1011import plotly .offline
1112
1213from wily import format_datetime , logger
14+ from wily .backend import WilyIndex
15+ from wily .cache import get_default_metrics_path
1316from wily .config .types import WilyConfig
17+ from wily .defaults import DEFAULT_ARCHIVER
1418from wily .operators import Metric , resolve_metric , resolve_metric_as_tuple
15- from wily .state import State
1619
1720
1821def metric_parts (metric ):
@@ -55,7 +58,13 @@ def graph(
5558 logger .debug ("Running graph command" )
5659
5760 data = []
58- state = State (config )
61+ archiver = config .archiver or DEFAULT_ARCHIVER
62+
63+ # Get path to parquet index
64+ parquet_path = get_default_metrics_path (config , archiver )
65+ if not Path (parquet_path ).exists ():
66+ logger .error ("Wily cache not found. Run 'wily build' first." )
67+ sys .exit (1 )
5968
6069 if x_axis is None :
6170 x_axis = "history"
@@ -66,80 +75,123 @@ def graph(
6675 metrics_list = metrics .split ("," )
6776
6877 y_metric = resolve_metric (metrics_list [0 ])
69-
70- if not aggregate :
71- tracked_files = set ()
72- for rev in state .index [state .default_archiver ].revisions :
73- tracked_files .update (rev .revision .tracked_files )
74- paths = tuple (tracked_file for tracked_file in tracked_files if any (path_startswith (tracked_file , p ) for p in path )) or path
75- else :
76- paths = path
77-
78- title = f"{ x_axis .capitalize ()} of { y_metric .description } { (' for ' + paths [0 ]) if len (paths ) == 1 else '' } { ' aggregated' if aggregate else '' } "
7978 operator , key = metric_parts (metrics_list [0 ])
79+
8080 z_axis : Metric | str
8181 if len (metrics_list ) == 1 : # only y-axis
8282 z_axis = z_operator = z_key = ""
8383 else :
8484 z_axis = resolve_metric (metrics_list [1 ])
8585 z_operator , z_key = metric_parts (metrics_list [1 ])
86- for path_ in paths :
87- current_path = str (Path (path_ ))
88- x = []
89- y = []
90- z = []
91- labels = []
92- last_y = None
93- for rev in state .index [state .default_archiver ].revisions :
94- try :
95- val = rev .get (config , state .default_archiver , operator , current_path , key )
96- if val != last_y or not changes :
97- y .append (val )
98- if z_axis :
99- z .append (
100- rev .get (
101- config ,
102- state .default_archiver ,
103- z_operator ,
104- current_path ,
105- z_key ,
106- )
107- )
108- if x_axis == "history" :
109- x .append (format_datetime (rev .revision .date ))
110- else :
111- x .append (
112- rev .get (
113- config ,
114- state .default_archiver ,
115- x_operator ,
116- current_path ,
117- x_key ,
118- )
119- )
120- labels .append (f"{ rev .revision .author_name } <br>{ rev .revision .message } " )
121- last_y = val
122- except KeyError :
123- # missing data
124- pass
125-
126- # Create traces
127- trace = go .Scatter (
128- x = x ,
129- y = y ,
130- mode = "lines+markers+text" if text else "lines+markers" ,
131- name = f"{ path_ } " ,
132- ids = state .index [state .default_archiver ].revision_keys ,
133- text = labels ,
134- marker = {
135- "size" : 0 if not z_axis else z ,
136- "color" : list (range (len (y ))),
137- # "colorscale": "Viridis",
138- },
139- xcalendar = "gregorian" ,
140- hoveron = "points+fills" ,
141- ) # type: ignore
142- data .append (trace )
86+
87+ # Initialize title with a default - will be updated once we know paths
88+ title = f"{ x_axis .capitalize ()} of { y_metric .description } "
89+
90+ with WilyIndex (parquet_path , [operator ]) as index :
91+ # Get all rows and organize by revision
92+ all_rows = list (index )
93+ if not all_rows :
94+ logger .error ("No data in cache. Run 'wily build' first." )
95+ sys .exit (1 )
96+
97+ # Get unique revisions sorted by date (oldest first for graph)
98+ revisions : dict [str , dict ] = {}
99+ for row in all_rows :
100+ rev_key = row ["revision" ]
101+ if rev_key not in revisions :
102+ revisions [rev_key ] = {
103+ "key" : rev_key ,
104+ "author" : row .get ("revision_author" , "Unknown" ),
105+ "message" : row .get ("revision_message" , "" ),
106+ "date" : row .get ("revision_date" , 0 ),
107+ }
108+ sorted_revisions = sorted (revisions .values (), key = lambda r : r ["date" ])
109+ revision_keys = [r ["key" ] for r in sorted_revisions ]
110+
111+ # Build lookup: {revision: {path: row_data}}
112+ revision_data : dict [str , dict [str , dict ]] = {}
113+ tracked_files : set [str ] = set ()
114+ for row in all_rows :
115+ rev_key = row ["revision" ]
116+ file_path = row ["path" ]
117+ if rev_key not in revision_data :
118+ revision_data [rev_key ] = {}
119+ revision_data [rev_key ][file_path ] = row
120+ tracked_files .add (file_path )
121+
122+ if not aggregate :
123+ paths = tuple (
124+ tracked_file
125+ for tracked_file in tracked_files
126+ if any (path_startswith (tracked_file , p ) or tracked_file .startswith (p ) for p in path )
127+ ) or path
128+ else :
129+ paths = path
130+
131+ title = f"{ x_axis .capitalize ()} of { y_metric .description } { (' for ' + paths [0 ]) if len (paths ) == 1 else '' } { ' aggregated' if aggregate else '' } "
132+
133+ for path_ in paths :
134+ current_path = str (Path (path_ ))
135+ x = []
136+ y = []
137+ z = []
138+ labels = []
139+ last_y = None
140+
141+ for rev in sorted_revisions :
142+ rev_key = rev ["key" ]
143+ rev_paths = revision_data .get (rev_key , {})
144+
145+ # Try exact match or path starting with current_path
146+ row = rev_paths .get (current_path )
147+ if row is None :
148+ # Try matching path that starts with current_path (for directories)
149+ for p , r in rev_paths .items ():
150+ if p .startswith (current_path ) or current_path .startswith (p ):
151+ row = r
152+ break
153+
154+ if row is None :
155+ continue
156+
157+ try :
158+ val = row .get (key )
159+ if val is None :
160+ continue
161+
162+ if val != last_y or not changes :
163+ y .append (val )
164+ if z_axis :
165+ z_val = row .get (z_key )
166+ z .append (z_val if z_val is not None else 0 )
167+ if x_axis == "history" :
168+ x .append (format_datetime (rev ["date" ]))
169+ else :
170+ x_val = row .get (x_key )
171+ x .append (x_val if x_val is not None else 0 )
172+ labels .append (f"{ rev ['author' ]} <br>{ rev ['message' ]} " )
173+ last_y = val
174+ except KeyError :
175+ # missing data
176+ pass
177+
178+ # Create traces
179+ trace = go .Scatter (
180+ x = x ,
181+ y = y ,
182+ mode = "lines+markers+text" if text else "lines+markers" ,
183+ name = f"{ path_ } " ,
184+ ids = revision_keys ,
185+ text = labels ,
186+ marker = {
187+ "size" : 0 if not z_axis else z ,
188+ "color" : list (range (len (y ))),
189+ },
190+ xcalendar = "gregorian" ,
191+ hoveron = "points+fills" ,
192+ ) # type: ignore
193+ data .append (trace )
194+
143195 if output :
144196 filename = output
145197 auto_open = False
@@ -159,3 +211,4 @@ def graph(
159211 filename = filename ,
160212 include_plotlyjs = plotlyjs , # type: ignore
161213 )
214+
0 commit comments