@@ -87,6 +87,7 @@ def import_excel(
8787 # --- data (per sheet) ---
8888 all_formulas = {} # sheet_name → list of formula info
8989 all_styles = {} # sheet_name → style info
90+ all_summaries = {} # sheet_name → list of {coord, formula, value}
9091
9192 for sheet_name in target_sheets :
9293 ws_data = wb_data [sheet_name ]
@@ -99,10 +100,13 @@ def import_excel(
99100 safe_name = _safe_sheet_name (sheet_name )
100101 parts .append (f"--- data:{ safe_name } ---" )
101102
102- csv_content = _extract_sheet_data (ws_data )
103+ csv_content , summary_rows = _extract_sheet_data (ws_data , ws_formula )
103104 parts .append (csv_content )
104105 parts .append ("" )
105106
107+ if summary_rows :
108+ all_summaries [sheet_name ] = summary_rows
109+
106110 # Collect formulas
107111 if ws_formula and include_formulas :
108112 formulas = _extract_formulas (ws_formula )
@@ -117,7 +121,7 @@ def import_excel(
117121
118122 # --- compute ---
119123 parts .append ("--- compute ---" )
120- compute_code = _generate_compute_section (all_formulas , target_sheets )
124+ compute_code = _generate_compute_section (all_formulas , target_sheets , all_summaries )
121125 parts .append (compute_code )
122126 parts .append ("" )
123127
@@ -149,13 +153,144 @@ def import_excel_to_file(
149153# Data Extraction
150154# =============================================================================
151155
152- def _extract_sheet_data (ws ) -> str :
153- """Extract sheet data as CSV string."""
154- rows = []
155- for row in ws .iter_rows (values_only = True ):
156- # Skip completely empty rows
157- if all (cell is None for cell in row ):
156+ def _looks_like_header_row (row : tuple ) -> bool :
157+ """
158+ Decide whether a row of cells looks like a column-header row.
159+
160+ True iff the row has at least one cell AND every non-empty cell is a
161+ string that's either:
162+ - non-numeric (e.g. "Region", "Q1 Sales", "%-of-total")
163+ - or short and obviously a label (no purely-numeric strings either).
164+
165+ A row of all-numeric values (`1, 1, 1, ...` or `2024, 2025, ...`) is
166+ NOT a header — it's data.
167+
168+ A mixed row ("Region", 100, 200, ...) is NOT a header either, because
169+ real headers don't mix labels and numbers in one row.
170+ """
171+ non_empty = [c for c in row if c is not None and str (c ).strip () != "" ]
172+ if not non_empty :
173+ return False # empty rows are never headers
174+
175+ for c in non_empty :
176+ # Numbers (int, float, bool) → not a header cell.
177+ if isinstance (c , (int , float , bool )) and not isinstance (c , str ):
178+ return False
179+ # Strings that parse as numbers → not header cells.
180+ if isinstance (c , str ):
181+ s = c .strip ()
182+ try :
183+ float (s )
184+ return False # purely numeric string
185+ except (ValueError , TypeError ):
186+ pass
187+ if s .startswith ("=" ):
188+ return False # formula text — not a header
189+ else :
190+ # datetime, etc. — not a header
191+ return False
192+ return True
193+
194+
195+ def _is_summary_row (row : tuple , formula_row : Optional [tuple ] = None ) -> bool :
196+ """
197+ Decide whether a row looks like a single-cell summary row at the bottom
198+ of a data block (e.g. just `=SUM(A1:I26)` in column A, rest empty).
199+
200+ Heuristic: at most one non-empty cell, AND either that cell is a formula
201+ in `formula_row`, or the cached value in `row` is a number while every
202+ other cell is empty.
203+ """
204+ non_empty_idx = [i for i , c in enumerate (row ) if c is not None and str (c ).strip () != "" ]
205+ if len (non_empty_idx ) > 1 :
206+ return False # multiple values → looks like real data
207+ if not non_empty_idx :
208+ return False # empty row, caller filters those separately
209+ # Exactly one value. If we have the formula-mode row, check it's a formula.
210+ if formula_row is not None :
211+ f = formula_row [non_empty_idx [0 ]]
212+ if isinstance (f , str ) and f .startswith ("=" ):
213+ return True
214+ # ArrayFormula objects also count as formulas.
215+ if f is not None and not isinstance (f , (int , float , bool , str )):
216+ return True
217+ return False
218+
219+
220+ def _extract_sheet_data (ws , ws_formula = None ) -> tuple [str , list [dict ]]:
221+ """Extract sheet data as CSV string. Returns (csv, summary_rows).
222+
223+ `summary_rows` is a list of {'coord', 'formula', 'value'} dicts for any
224+ formula-only trailing rows that were filtered out of the data CSV.
225+ """
226+ # Collect all rows up front so we can decide:
227+ # 1) whether row 1 is a header
228+ # 2) whether trailing rows are summary-only
229+ raw_rows = list (ws .iter_rows (values_only = True ))
230+ formula_rows = (
231+ list (ws_formula .iter_rows (values_only = True ))
232+ if ws_formula is not None
233+ else [None ] * len (raw_rows )
234+ )
235+
236+ # ── 1. Drop trailing summary-only rows (e.g. `=SUM(A1:I26)` in col A).
237+ summary_rows = []
238+ end = len (raw_rows )
239+ while end > 0 :
240+ # Skip blank rows from the bottom.
241+ cur = raw_rows [end - 1 ]
242+ if all (c is None or str (c ).strip () == "" for c in cur ):
243+ end -= 1
158244 continue
245+ # Is this a single-cell summary row?
246+ f_row = formula_rows [end - 1 ] if end - 1 < len (formula_rows ) else None
247+ if _is_summary_row (cur , f_row ):
248+ non_empty_idx = [i for i , c in enumerate (cur ) if c is not None and str (c ).strip () != "" ]
249+ i = non_empty_idx [0 ]
250+ coord = f"{ get_column_letter (i + 1 )} { end } "
251+ f_val = f_row [i ] if f_row is not None else None
252+ # Normalize ArrayFormula → its formula text.
253+ if hasattr (f_val , "text" ):
254+ f_text = f_val .text
255+ elif isinstance (f_val , str ) and f_val .startswith ("=" ):
256+ f_text = f_val
257+ else :
258+ f_text = None
259+ summary_rows .append ({
260+ "coord" : coord ,
261+ "formula" : f_text ,
262+ "value" : cur [i ],
263+ })
264+ end -= 1
265+ continue
266+ break # found a real data row, stop trimming
267+
268+ summary_rows .reverse () # restore top-to-bottom order
269+
270+ # ── 2. Drop trailing blank rows that came BEFORE the now-last data row,
271+ # AND any rows we already trimmed.
272+ rows_in = raw_rows [:end ]
273+ # Filter blank rows everywhere (preserves prior behaviour for the body).
274+ body_rows = [r for r in rows_in if not all (c is None for c in r )]
275+
276+ if not body_rows :
277+ return "" , summary_rows
278+
279+ # ── 3. Decide whether row 1 is a header or data.
280+ has_header = _looks_like_header_row (body_rows [0 ])
281+ if has_header :
282+ header_row = body_rows [0 ]
283+ data_rows = body_rows [1 :]
284+ headers = [_clean_column_name (str (c ) if c is not None else "" ) for c in header_row ]
285+ else :
286+ # Synthesize col_A, col_B, ... headers from column count.
287+ n_cols = max (len (r ) for r in body_rows )
288+ headers = [f"col_{ get_column_letter (i + 1 )} " for i in range (n_cols )]
289+ data_rows = body_rows
290+
291+ # ── 4. Serialize.
292+ csv_lines = ["," .join (headers )]
293+ for row in data_rows :
159294 csv_row = []
160295 for cell in row :
161296 if cell is None :
@@ -166,22 +301,12 @@ def _extract_sheet_data(ws) -> str:
166301 csv_row .append (str (int (cell )))
167302 else :
168303 val = str (cell )
169- # Quote if contains comma or quotes
170- if ',' in val or '"' in val or '\n ' in val :
304+ if "," in val or '"' in val or "\n " in val :
171305 val = '"' + val .replace ('"' , '""' ) + '"'
172306 csv_row .append (val )
173- rows .append (',' .join (csv_row ))
174-
175- if not rows :
176- return ""
307+ csv_lines .append ("," .join (csv_row ))
177308
178- # Clean column headers (make valid Python identifiers)
179- if rows :
180- headers = rows [0 ].split (',' )
181- clean_headers = [_clean_column_name (h ) for h in headers ]
182- rows [0 ] = ',' .join (clean_headers )
183-
184- return '\n ' .join (rows )
309+ return "\n " .join (csv_lines ), summary_rows
185310
186311
187312def _clean_column_name (name : str ) -> str :
@@ -219,18 +344,35 @@ def _clean_column_name(name: str) -> str:
219344
220345
221346def _extract_formulas (ws ) -> list [dict ]:
222- """Extract formulas from worksheet."""
347+ """Extract formulas from worksheet.
348+
349+ Handles both plain `=SUM(...)` strings and openpyxl ArrayFormula
350+ objects (dynamic-array formulas). The latter would otherwise be
351+ silently dropped because their `.value` is not a string.
352+ """
223353 formulas = []
224354 for row in ws .iter_rows ():
225355 for cell in row :
226- if cell .value and isinstance (cell .value , str ) and cell .value .startswith ('=' ):
227- formulas .append ({
228- 'cell' : cell .coordinate ,
229- 'column' : get_column_letter (cell .column ),
230- 'col_idx' : cell .column - 1 ,
231- 'row' : cell .row ,
232- 'formula' : cell .value ,
233- })
356+ v = cell .value
357+ if v is None :
358+ continue
359+ # Plain text formula.
360+ if isinstance (v , str ) and v .startswith ('=' ):
361+ formula_text = v
362+ # openpyxl ArrayFormula object — has a .text attribute.
363+ elif hasattr (v , 'text' ) and isinstance (getattr (v , 'text' , None ), str ):
364+ formula_text = v .text
365+ if not formula_text .startswith ('=' ):
366+ formula_text = '=' + formula_text
367+ else :
368+ continue
369+ formulas .append ({
370+ 'cell' : cell .coordinate ,
371+ 'column' : get_column_letter (cell .column ),
372+ 'col_idx' : cell .column - 1 ,
373+ 'row' : cell .row ,
374+ 'formula' : formula_text ,
375+ })
234376 return formulas
235377
236378
@@ -470,36 +612,62 @@ def _convert_range_to_col(range_str: str, headers: list[str] = None) -> str:
470612 return range_str
471613
472614
473- def _generate_compute_section (all_formulas : dict , sheet_names : list [str ]) -> str :
615+ def _generate_compute_section (
616+ all_formulas : dict ,
617+ sheet_names : list [str ],
618+ all_summaries : Optional [dict ] = None ,
619+ ) -> str :
474620 """Generate compute section from extracted formulas."""
475621 lines = []
476622 lines .append ("def transform(df):" )
477623
478624 has_formulas = any (formulas for formulas in all_formulas .values ())
625+ has_summaries = bool (all_summaries ) and any (s for s in (all_summaries or {}).values ())
479626
480- if has_formulas :
627+ if has_formulas or has_summaries :
481628 lines .append (" # Auto-converted from Excel formulas" )
482629 lines .append (" # Review and adjust as needed" )
483630 lines .append ("" )
484631
485- for sheet_name , formulas in all_formulas .items ():
486- if len (all_formulas ) > 1 :
487- lines .append (f" # --- Sheet: { sheet_name } ---" )
488-
489- # Group formulas by type
490- seen_patterns = set ()
491- for f_info in formulas :
492- python_code = _convert_formula_to_python (f_info ['formula' ])
493- # Avoid duplicates (same formula applied to many rows)
494- pattern_key = re .sub (r'\d+' , 'N' , f_info ['formula' ])
495- if pattern_key not in seen_patterns :
496- seen_patterns .add (pattern_key )
497- if python_code .startswith ('#' ):
498- lines .append (f" { python_code } " )
499- else :
500- lines .append (f" # Cell { f_info ['cell' ]} : { f_info ['formula' ]} " )
501- lines .append (f" # → { python_code } " )
502- lines .append ("" )
632+ # Summaries (trailing single-cell formula rows) come first as a clearly
633+ # labelled block, since they were extracted out of the data table.
634+ if has_summaries :
635+ for sheet_name , summaries in all_summaries .items ():
636+ if not summaries :
637+ continue
638+ if len (all_summaries ) > 1 :
639+ lines .append (f" # --- Summary cells for sheet: { sheet_name } ---" )
640+ else :
641+ lines .append (" # --- Summary cells (trailing rows below data) ---" )
642+ for s in summaries :
643+ coord = s .get ("coord" , "?" )
644+ formula = s .get ("formula" ) or "(array formula)"
645+ value = s .get ("value" )
646+ py_hint = _convert_formula_to_python (formula ) if formula else ""
647+ lines .append (f" # Cell { coord } : { formula } (cached value: { value } )" )
648+ if py_hint and not py_hint .startswith ("#" ):
649+ lines .append (f" # → { py_hint } " )
650+ lines .append ("" )
651+
652+ if has_formulas :
653+ for sheet_name , formulas in all_formulas .items ():
654+ if len (all_formulas ) > 1 :
655+ lines .append (f" # --- Sheet: { sheet_name } ---" )
656+
657+ # Group formulas by type
658+ seen_patterns = set ()
659+ for f_info in formulas :
660+ python_code = _convert_formula_to_python (f_info ['formula' ])
661+ # Avoid duplicates (same formula applied to many rows)
662+ pattern_key = re .sub (r'\d+' , 'N' , f_info ['formula' ])
663+ if pattern_key not in seen_patterns :
664+ seen_patterns .add (pattern_key )
665+ if python_code .startswith ('#' ):
666+ lines .append (f" { python_code } " )
667+ else :
668+ lines .append (f" # Cell { f_info ['cell' ]} : { f_info ['formula' ]} " )
669+ lines .append (f" # → { python_code } " )
670+ lines .append ("" )
503671 else :
504672 lines .append (" # No formulas detected — add your transformations here" )
505673 lines .append (" pass" )
0 commit comments