Engine: python.set makes a Python cell; this build stores code and

doesn't run it

python.set {cell?, code, output?: object|value} makes the cell (the
active cell by default) a Python cell, keeping its style and taking a
formula and its spill away. `output` is the return type Microsoft
documents for PY(): an Excel value (the default) or a Python object;
without it a Python cell keeps the one it had. The code must fit in
Excel's 8,192-character formula once written as PY(...), quotes doubled
and literals joined, so the command checks the encoded formula rather
than a character count. A table's header row refuses Python (it holds
the column's name, as Excel keeps formulas out of headers), and a locked
cell on a protected sheet refuses it through the usual protection check.

Nothing runs Python here. Unchanged code keeps its cached result and
value; new or changed code shows #BLOCKED!. One undo step takes the cell
back.

Sheet::clear_spill removes the spilled values inside a range with work
bounded by the values stored, not by the range's size.

In the interface, F2, a double click or the formula bar on a Python cell
edits its code, and a changed text is committed through python.set,
which keeps the return type; through cell.set it would have replaced the
Python cell with an ordinary cell holding the code as text. Typing over
a Python cell replaces it, as typing over a formula does, and committing
the code unchanged writes nothing. Deleting all the code clears the
cell through cell.set, as an empty entry clears any other cell; code
of spaces is still code.

Tests: the code and return type in cell.get, #BLOCKED! for new code,
cached results kept for unchanged code, the active-cell default, undo,
refused params (missing or non-text code, a bad cell, a bad return
type), the encoded limit at 8,079 and 8,080 characters and with doubled
quotes, a table header, a protected sheet, and a formula's spill taken
away. A UI test edits a Python cell's code with F2 and Enter, commits it
unchanged, and types over it; another clears a Python cell by deleting
all its code, keeps code of spaces, and undoes the clearing.
This commit is contained in:
prxnvt
2026-10-26 10:18:38 -04:00
parent 44c663aea2
commit c6c54c57e6
9 changed files with 362 additions and 6 deletions
+2
View File
@@ -14,6 +14,7 @@ pub mod insert;
pub mod inspect;
pub mod pivot;
pub mod print;
pub mod python;
pub mod review;
pub mod sheet;
pub mod slicer;
@@ -147,6 +148,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(python::specs());
v.extend(slicer::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
+96
View File
@@ -0,0 +1,96 @@
//! Python in cells: making and editing Python cells. This build stores the code and the last
//! result a file cached; it doesn't run Python, so a cell with new or changed code shows
//! `#BLOCKED!`.
use std::sync::Arc;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS};
use serde_json::{Value as Json, json};
use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"python.set",
"Set Python Code",
[],
None,
"{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)",
has_doc,
set
)]
}
fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> {
let sheet = target_sheet(s, p)?;
let at = match cell_param_on(s, p, "cell", sheet)? {
Some(c) => c,
None => s.doc()?.selection.active,
};
Ok((sheet, at))
}
fn existing(s: &Session, sheet: usize, at: CellRef) -> Result<Option<PyCode>> {
Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned())
}
fn output_mode(p: &Json, key: &str) -> Result<Option<bool>> {
match str_param(p, key) {
None => Ok(None),
Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)),
Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)),
Some(m) => Err(bad("python.set", format!("`{key}` is `object` or `value`, not `{m}`"))),
}
}
fn set(s: &mut Session, p: &Json) -> Result<Json> {
let (sheet, at) = target_cell(s, p)?;
let code = match p.get("code") {
Some(Json::String(c)) => c.as_str(),
Some(_) => return Err(bad("python.set", "`code` must be text")),
None => return Err(bad("python.set", "missing `code`")),
};
let mode = output_mode(p, "output")?;
let old = existing(s, sheet, at)?;
let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false);
let mut py = PyCode { object, ..PyCode::new(code) };
if !py.fits() {
return Err(bad(
"python.set",
format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"),
));
}
// A table header is the column's name, never a formula or code (as in Excel).
let header =
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at)));
if header {
return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1())));
}
// Unchanged code keeps its result (and its value); new code has none until it runs.
let keep = old.filter(|o| o.code == py.code);
let value = match &keep {
Some(o) => {
py.output = o.output.clone();
py.stale = o.stale;
None
}
None => Some(gridcraft_model::python::BLOCKED),
};
edit(s, |cx| {
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(at);
let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty));
// A formula's spill (or the old result's) goes with what the cell held.
if let Some(r) = sh.spill_ranges.remove(&at) {
sh.clear_spill(r);
}
sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) });
cx.touch(sheet, at);
Ok(())
})?;
let d = s.doc()?;
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)}))
}
+1
View File
@@ -5094,6 +5094,7 @@ fn a_script_cannot_switch_windows() {
}
mod python_cells;
mod python_commands;
mod seams;
#[test]
@@ -0,0 +1,91 @@
//! The python.* commands.
use gridcraft_core::{CellError, Value};
use serde_json::json;
use super::{s, v};
fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value {
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap()
}
#[test]
fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() {
let mut s = s();
let r = set(&mut s, "B1", "xl('A1') + 1");
assert_eq!(r["cell"], "B1");
assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked));
let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap();
assert_eq!(got["python"]["code"], "xl('A1') + 1");
assert_eq!(got["python"]["output"], "value");
assert_eq!(got["input"], "xl('A1') + 1");
// The active cell by default.
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
s.execute("python.set", json!({"code": "2"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2");
// One undo step takes the cell back.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null());
}
#[test]
fn unchanged_code_keeps_its_cached_result() {
let mut s = s();
set(&mut s, "A1", "21 * 2");
// As a file would have cached it.
{
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let sh = wb.sheet_mut(0).unwrap();
let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap();
let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap());
py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true });
cell.value = Value::Number(42.0);
}
set(&mut s, "A1", "21 * 2");
assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result");
s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap();
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object");
set(&mut s, "A1", "21 * 3");
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet");
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays");
}
#[test]
fn set_refuses_bad_params_long_code_headers_and_protected_cells() {
let mut s = s();
for p in [
json!({"cell": "A1"}),
json!({"cell": "A1", "code": 5}),
json!({"cell": "not a cell", "code": "1"}),
json!({"cell": "A1", "code": "1", "output": "sideways"}),
] {
assert!(s.execute("python.set", p.clone()).is_err(), "{p}");
}
// The limit is the stored PY(…) formula's 8,192 characters.
assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err());
s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap();
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err();
assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}");
// A table header is the column's name.
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err());
assert_eq!(v(&s, "D1"), Value::text("Score"));
set(&mut s, "D2", "1");
// A locked cell on a protected sheet refuses it.
s.execute("review.protectSheet", json!({})).unwrap();
let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err();
assert!(e.to_string().contains("protected"), "{e}");
}
#[test]
fn set_over_a_spilling_formula_takes_the_spill_away() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
assert_eq!(v(&s, "A3"), Value::Number(3.0));
set(&mut s, "A1", "[1, 2, 3]");
assert_eq!(v(&s, "A3"), Value::Empty);
assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty());
}