Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions crates/engine/src/cmd/gp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,9 @@ pub fn tool_complete(s: &mut Session, id: u64, done: ToolDone) -> Result<(), Str
}

#[cfg(not(target_arch = "wasm32"))]
/// A tool's run made ahead on a copy of the session, for a model the app runs on a thread of its
/// own ([`super::workflows::model_ahead`]): what it read and what it made, for the model's run
/// here to take instead of running the tool again.
/// A tool's run made ahead on a copy of the session, for a model or script the app runs on a
/// thread of its own ([`super::workflows::ahead_on_copy`]): what it read and what it made, for
/// the run here to take instead of running the tool again.
pub(crate) struct RanAhead {
tool: String,
params: Value,
Expand All @@ -526,17 +526,18 @@ pub(crate) struct RanAhead {
}

#[cfg(not(target_arch = "wasm32"))]
/// The tools of a model run ahead: recorded on the copy, then taken in order by the run here.
/// The tools of a model or script run ahead: recorded on the copy, then taken in order by the run
/// here.
pub(crate) enum Ahead {
Recording(Vec<RanAhead>),
Taking(std::collections::VecDeque<RanAhead>),
}

#[cfg(not(target_arch = "wasm32"))]
/// What a model's run on a copy of the session stops at, as its run here does them: a tool that
/// writes files (which would be written twice), and plugin and GeoLibre tools, which run as they
/// always have.
const NOT_AHEAD: &str = "runs where the model runs";
/// What a model's or script's run on a copy of the session stops at, as its run here does them: a
/// tool that writes files (which would be written twice), plugin and GeoLibre tools, which run as
/// they always have, and a script's line that may reach outside the session.
pub(crate) const NOT_AHEAD: &str = "runs where the model or script runs";

#[cfg(not(target_arch = "wasm32"))]
/// Runs `run` taking the tools `ran` ahead: each one whose inputs are still the same as the run
Expand Down Expand Up @@ -1167,7 +1168,7 @@ pub fn run_tool_with(
#[cfg(not(target_arch = "wasm32"))]
let view = inputs.view.get().copied();
drop(inputs);
// A file made, with a SaveFile parameter or not, is written where the model runs.
// A file made, with a SaveFile parameter or not, is written where the model or script runs.
#[cfg(not(target_arch = "wasm32"))]
if recording && matches!(&res, Ok(r) if matches!(r.output, Output::File { .. })) {
return Err(NOT_AHEAD.into());
Expand Down
16 changes: 9 additions & 7 deletions crates/engine/src/cmd/reads.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! Reads the desktop app makes on a thread of its own before a command adds what they read
//! (#1291 step 3): Add Data's whole-file reads (a raster, a raster GDAL converts, a GeoJSON,
//! delimited text or spreadsheet read whole), Load All Features, and a model's tools, run on a
//! copy of the session. [`read_ahead`] takes what
//! the read needs of the session, [`ReadAhead::run`] reads on the thread, and the app then runs
//! the command as it would have with the read [`Read::placed`] in the session: the command takes
//! it instead of reading again, so what it adds, says, logs and puts in Undo is the same as when
//! it reads here. A script or the command line reads where the command runs.
//! delimited text or spreadsheet read whole), Load All Features, and a model's or a script's
//! tools, run on a copy of the session. [`read_ahead`] takes what the read needs of the session,
//! [`ReadAhead::run`] reads on the thread, and the app then runs the command as it would have
//! with the read [`Read::placed`] in the session: the command takes it instead of reading again,
//! so what it adds, says, logs and puts in Undo is the same as when it reads here. A script's own
//! Add Data lines, and the command line, read where the command runs.

use std::any::Any;

Expand Down Expand Up @@ -64,7 +64,9 @@ pub fn read_ahead(s: &Session, command: &str, p: &Value) -> Option<ReadAhead> {
"layer.addData" => super::layer::add_data_ahead(p),
"layer.loadAll" => super::layer::load_all_ahead(s, p),
#[cfg(not(target_arch = "wasm32"))]
"model.run" => Some(super::workflows::model_ahead(s, p)),
"model.run" => Some(super::workflows::ahead_on_copy(s, "model.run", p)),
#[cfg(not(target_arch = "wasm32"))]
"script.run" => super::workflows::script_ahead(s, p),
_ => None,
}
}
Expand Down
88 changes: 78 additions & 10 deletions crates/engine/src/cmd/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ fn model_graph(s: &Session, p: &Value) -> Result<(String, crate::project::ModelG
}

/// Runs a model's graph, or the graph passed: taking the tools the app ran ahead
/// ([`model_ahead`]) whose inputs are still the same.
/// ([`ahead_on_copy`]) whose inputs are still the same.
fn run_model(s: &mut Session, p: &Value) -> CmdResult {
#[cfg(not(target_arch = "wasm32"))]
if let Some(ran) = super::reads::take::<Vec<super::gp::RanAhead>>(s, MODEL_AHEAD) {
Expand All @@ -511,33 +511,86 @@ fn run_model(s: &mut Session, p: &Value) -> CmdResult {
}

#[cfg(not(target_arch = "wasm32"))]
/// The key of a model's tools run ahead ([`model_ahead`]).
/// The key of a model's tools run ahead ([`ahead_on_copy`]).
const MODEL_AHEAD: &str = "model.run";

#[cfg(not(target_arch = "wasm32"))]
/// A model's run on a copy of the session, made on a thread of its own: it records what each of
/// its tools read and made, for `model.run` to take instead of running them again, so what the
/// run adds, says, logs and puts in Undo is the same as when it runs here. It stops at a tool
/// that writes files, a plugin's or GeoLibre's: from there the run here runs them as it always
/// has. What it says and how it ends are the run here's to say.
pub(crate) fn model_ahead(s: &Session, p: &Value) -> super::reads::ReadAhead {
/// The key of a script's tools run ahead ([`ahead_on_copy`]).
const SCRIPT_AHEAD: &str = "script.run";

#[cfg(not(target_arch = "wasm32"))]
/// `command` (`model.run` or `script.run`) run on a copy of the session, made on a thread of its
/// own: it records what each tool it runs read and made, for the command to take instead of
/// running them again, so what the run adds, says, logs and puts in Undo is the same as when it
/// runs here. It stops at a tool that writes files, a plugin's or GeoLibre's, and at a script's
/// line that may reach outside the session ([`runs_on_a_copy`]): from there the run here runs
/// them as it always has. What it says and how it ends are the run here's to say.
pub(crate) fn ahead_on_copy(s: &Session, command: &'static str, p: &Value) -> super::reads::ReadAhead {
let name = if command == SCRIPT_AHEAD { "Run Script" } else { "Run Model" };
let (project, trusted) = (s.project.clone(), s.trusted_models.clone());
Comment thread
pick2510 marked this conversation as resolved.
Resolved
let (view_size, active_layer, p) = (s.view_size, s.active_layer, p.clone());
// What its tools ask of servers and files goes in this session's Diagnostics, as when they
// run here: the run here takes their results and asks nothing.
let requests = s.diagnostics.capture();
super::reads::ahead("Run Model", MODEL_AHEAD.into(), move || {
super::reads::ahead(name, command.into(), move || {
let mut copy = Session::new(project);
copy.requests_into = Some(requests);
copy.trusted_models = trusted;
copy.view_size = view_size;
copy.active_layer = active_layer;
copy.tools_ahead = Some(super::gp::Ahead::Recording(Vec::new()));
let _ = copy.run("model.run", &p);
let _ = copy.run(command, &p);
super::gp::ran_ahead(&mut copy)
})
}

#[cfg(not(target_arch = "wasm32"))]
/// A script's run on a copy of the session ([`ahead_on_copy`]), unless its first line would stop
/// the copy at once (or the script doesn't read): then it runs here, with no copy made.
Comment thread
pick2510 marked this conversation as resolved.
Resolved
pub(crate) fn script_ahead(s: &Session, p: &Value) -> Option<super::reads::ReadAhead> {
let lines = script_commands(ps(p, "script")?).ok()?;
let (_, id, params) = lines.first()?;
runs_on_a_copy(id, params).then(|| ahead_on_copy(s, SCRIPT_AHEAD, p))
}

#[cfg(not(target_arch = "wasm32"))]
/// The commands a script's line may run on a copy of the session ([`runs_on_a_copy`]): those
/// whose results are taken (a tool, a model) and those that change only what the taking compares
/// by value (a selection, a layer's name or query, the environment, the projection). A line that
/// makes rows of its own (an edit, a layer from a selection) isn't one: the rows it made on the
/// copy are never the run here's, so every tool after it would run again.
pub(crate) const ON_A_COPY: &[&str] = &[
"model.run",
"gp.run",
"gp.spatialJoin",
Comment thread
pick2510 marked this conversation as resolved.
Resolved
"gp.zonalStats",
"gp.env",
"select.all",
"select.atPoint",
"select.byAttributes",
"select.byPolygon",
"select.byRadius",
"select.byRect",
"select.clear",
"select.rows",
"select.switch",
"layer.remove",
"layer.rename",
"layer.definitionQuery",
"layer.select",
"map.projection",
];

#[cfg(not(target_arch = "wasm32"))]
/// Whether a script's line runs on a copy of the session ([`ahead_on_copy`]): one of
/// [`ON_A_COPY`], naming no file: run twice, none changes anything outside the session. Any
/// other line stops the copy there, Add Data and Load All among them (they would read their
/// files and servers twice).
fn runs_on_a_copy(id: &str, params: &Value) -> bool {
// A model's own tools are checked as they run; any other line by what its parameters name.
ON_A_COPY.contains(&id) && (id == "model.run" || !super::items::touches_files(id, params))
}

fn run_model_here(s: &mut Session, p: &Value) -> CmdResult {
// A model saved as steps runs as steps: `%n` may stand anywhere in them, and inputs may be files.
if p.get("graph").is_none_or(Value::is_null) {
Expand Down Expand Up @@ -658,11 +711,26 @@ fn wire(v: &mut Value, outputs: &[String], step: usize, depth: usize) -> Result<
/// any command, files included; notebooks vet cells that came from files before running them.
/// The whole script is one undo step.
pub(crate) fn run_script(s: &mut Session, p: &Value) -> CmdResult {
#[cfg(not(target_arch = "wasm32"))]
if let Some(ran) = super::reads::take::<Vec<super::gp::RanAhead>>(s, SCRIPT_AHEAD) {
return super::gp::taking(s, ran, |s| run_script_here(s, p));
}
run_script_here(s, p)
}

/// Runs a script's lines in order ([`run_script`]).
fn run_script_here(s: &mut Session, p: &Value) -> CmdResult {
let script = ps(p, "script").ok_or("script is required")?;
let mut out = Vec::new();
for (n, id, params) in script_commands(script)? {
let spec = s.registry.get(&id).ok_or_else(|| format!("line {n}: unknown command '{id}'"))?;
let (run, enabled, mutates, label) = (spec.run, spec.enabled, spec.mutates, spec.label);
// On a copy of the session ([`ahead_on_copy`]): from a line that may reach outside it,
// the run here runs the rest.
#[cfg(not(target_arch = "wasm32"))]
if matches!(s.tools_ahead, Some(super::gp::Ahead::Recording(_))) && !runs_on_a_copy(&id, &params) {
return Err(super::gp::NOT_AHEAD.into());
}
// As when typed directly, a line naming a layer is checked against that layer.
if params.get("layer").is_some()
&& let Ok(lid) = s.layer_id(&params)
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ pub struct Session {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) tools_ahead: Option<cmd::gp::Ahead>,
/// Where the requests a copy of the session makes for a model's run ahead go
/// ([`cmd::workflows::model_ahead`]): the session's own Diagnostics, as when they're made
/// ([`cmd::workflows::ahead_on_copy`]): the session's own Diagnostics, as when they're made
/// there. Its notices stay its own: the run there says them.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) requests_into: Option<rustgis_data::diagnostics::Capture>,
Expand Down
80 changes: 80 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28840,3 +28840,83 @@ fn a_models_tools_run_ahead_are_taken_by_its_run() {
assert_eq!(s.map().layer_by_name("Rings").unwrap().dataset.features.len(), 1);
std::fs::remove_dir_all(&dir).ok();
}

#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_scripts_tools_run_ahead_are_taken_by_its_run() {
use rustgis_analysis::control::Control;
let session = || {
let mut s = Session::new(Project::default());
let mut points = rustgis_data::Dataset::new("Points", rustgis_data::GeometryType::Point, Vec::new());
for (x, y) in [(8.0, 47.0), (9.0, 46.0), (10.0, 45.0)] {
points.push_next(Some(rustgis_data::Geometry::Point(rustgis_geom::Coord::new(x, y))), Vec::new());
}
s.map_mut().add_layer(rustgis_map::Layer::new(0, points));
s.run("model.new", &json!({"name": "Steps", "steps": [{"tool": "buffer", "params": {"input": "Points", "distance": 1000}}]}))
.unwrap();
s
};
let tool = |d: u32| format!(r#"gp.run {{"tool": "buffer", "params": {{"input": "Points", "distance": {d}}}}}"#);
let layers = |s: &Session| {
s.map()
.layers
.iter()
.map(|l| (l.name.clone(), format!("{:?}", l.dataset.features.iter().map(|f| &f.geometry).collect::<Vec<_>>())))
.collect::<Vec<_>>()
};

// Its tools, a selection between them and a model it runs are taken: what the run adds and
// keeps in history is the same, as one step to undo.
let p = json!({"script": format!("{}\nselect.all {{\"layer\": \"Points\"}}\n{}\nmodel.run {{\"name\": \"Steps\"}}", tool(1000), tool(2000))});
let mut here = session();
here.run("script.run", &p).unwrap();
let mut s = session();
let depth = s.undo_depth();
crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s);
let r = s.run("script.run", &p).unwrap();
assert_eq!(s.tools_taken, 3, "{r}");
assert!(s.read_ahead.is_none() && s.tools_ahead.is_none());
assert_eq!(layers(&s), layers(&here));
assert_eq!(s.project.gp_history.len(), here.project.gp_history.len());
assert_eq!(s.undo_depth(), depth + 1);

// A line that may reach outside the session stops the copy: the run here runs it and what
// follows, so the project is saved once.
let dir = std::env::temp_dir().join(format!("rustgis-script-ahead-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let saved = dir.join("saved.rgis");
let p = json!({"script": format!("{}\nproject.save {}\n{}", tool(1000), json!({"path": saved.display().to_string()}), tool(2000))});
let mut s = session();
crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s);
assert!(!saved.exists(), "the copy saved nothing");
s.run("script.run", &p).unwrap();
assert_eq!(s.tools_taken, 1);
assert!(saved.exists());
assert_eq!(s.project.gp_history.len(), 2);
std::fs::remove_dir_all(&dir).ok();

// An edit stops the copy (the rows it makes there are never the run here's): the tool
// before it is taken, the one after it runs here.
let p = json!({"script": format!("{}\nedit.addField {{\"layer\": \"Points\", \"name\": \"n\", \"type\": \"long\"}}\n{}", tool(1000), tool(2000))});
let mut s = session();
crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s);
s.run("script.run", &p).unwrap();
assert_eq!(s.tools_taken, 1);
assert!(s.map().layer_by_name("Points").unwrap().dataset.fields.iter().any(|f| f.name == "n"));

// A selection changed meanwhile: the tools after it run here, on it.
let p = json!({"script": tool(1000)});
let mut s = session();
crate::cmd::reads::read_ahead(&s, "script.run", &p).unwrap().run(&Control::stoppable()).unwrap().placed(&mut s);
s.map_mut().layers[0].selection = vec![0];
s.run("script.run", &p).unwrap();
assert_eq!(s.tools_taken, 0);
// A script whose first line would stop the copy runs here, with no copy made.
let p = json!({"script": format!("edit.addField {{\"layer\": \"Points\", \"name\": \"m\", \"type\": \"long\"}}\n{}", tool(1000))});
assert!(crate::cmd::reads::read_ahead(&s, "script.run", &p).is_none());
assert!(crate::cmd::reads::read_ahead(&s, "script.run", &json!({"script": "# nothing"})).is_none());
// Each command a line may run on the copy is one the session has.
for id in crate::cmd::workflows::ON_A_COPY {
assert!(s.registry.get(id).is_some(), "{id}");
}
}
Loading
Loading