diff --git a/crates/engine/src/tests/python_files.rs b/crates/engine/src/tests/python_files.rs
index 5f6d3343c..d540b1bba 100644
--- a/crates/engine/src/tests/python_files.rs
+++ b/crates/engine/src/tests/python_files.rs
@@ -292,6 +292,31 @@ fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() {
assert_eq!(warnings.iter().filter(|w| w.contains("Python part is damaged")).count(), 1, "{warnings:?}");
}
+#[test]
+fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
+ // A1's cached result spills over A1:A3; C1 is an ordinary dynamic array with a stale cache.
+ let data = "_xlfn._xlws.PY(0,0)1\
+ SEQUENCE(3)1
\
+ 29
\
+ 39
";
+ let missing = excel_shaped(data, None);
+ let damaged = with_damaged_part(&excel_shaped(data, Some(&["[1, 2, 3]"])), "xl/python/python1.xml", "");
+ for (name, bytes) in [("missing.xlsx", missing), ("damaged.xlsx", damaged)] {
+ let mut s = s();
+ open(&mut s, name, &bytes);
+ assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0), Value::Number(3.0)), "{name}");
+ let sh = s.doc().unwrap().wb.active().unwrap();
+ for a in ["A1", "A2", "A3"] {
+ let cell = sh.cell(CellRef::parse(a).unwrap()).unwrap_or_else(|| panic!("{name}: {a} is stored"));
+ assert!(cell.py.is_none() && cell.formula.is_none(), "{name}: {a} is a plain value");
+ }
+ assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "{name}: no live spill");
+ // The ordinary formula fills its spill again, over the stale cached values.
+ assert_eq!((v(&s, "C2"), v(&s, "C3")), (Value::Number(2.0), Value::Number(3.0)), "{name}");
+ assert!(sh.cell(CellRef::parse("C2").unwrap()).is_none(), "{name}: C2 is the formula's spill, not a cell");
+ }
+}
+
#[test]
fn many_cells_naming_one_long_script_take_from_one_budget() {
// 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy.
diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs
index 864c97c6d..7417e772a 100644
--- a/crates/xlsx/src/sheet_read.rs
+++ b/crates/xlsx/src/sheet_read.rs
@@ -79,6 +79,20 @@ fn push_dynamic(st: &mut RowState, range: RangeRef) {
st.dynamic.push(range);
}
+/// Forgets the range anchored at `pos` (a Python cell whose code the file doesn't hold, which
+/// keeps only its cached value): the cached values over the rest of its spill are kept as plain
+/// values too, as the file shows them, rather than left for a result that won't come. Charged
+/// like [`dynamic_range`]'s scans.
+fn forget_dynamic(st: &mut RowState, pos: CellRef) {
+ if st.dynamic_checks > MAX_DYNAMIC_CHECKS {
+ return;
+ }
+ st.dynamic_checks = st.dynamic_checks.saturating_add(st.dynamic.len() as u64);
+ if let Some(i) = st.dynamic.iter().rposition(|r| r.start == pos) {
+ st.dynamic.remove(i);
+ }
+}
+
const MAX_HYPERLINK_CELLS: u64 = 10_000;
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
@@ -527,7 +541,8 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
// A `PY(index,0|1)` formula is a Python cell whose code is a script of the Python part. The
// older `PY("…",0|1)` form is read from the formula's own text: the formula parser would fold
- // line breaks in the code. A formula naming a script the part lacks keeps only its value.
+ // line breaks in the code. A formula naming a script the part lacks keeps only its cached
+ // values, its own and its spill's, as plain values.
let py = match (&formula, c.child("f")) {
(Some(fm), Some(f)) => crate::python_part::cell_code(&mut cx.python, fm, &f.text),
_ => crate::python_part::PyFormula::Not,
@@ -535,7 +550,10 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
let (formula, py) = match py {
crate::python_part::PyFormula::Not => (formula, None),
crate::python_part::PyFormula::Code(py) => (None, Some(Arc::new(py))),
- crate::python_part::PyFormula::Missing => (None, None),
+ crate::python_part::PyFormula::Missing => {
+ forget_dynamic(st, pos);
+ (None, None)
+ }
};
let spilled_from = if formula.is_none() && py.is_none() { dynamic_range(st, pos) } else { None };
let value = match spilled_from {