Files
windmill/backend/tests/python_jobs.rs
hugocasa bffa61e33f fix: dedicated worker dispatch, cross-workspace deps, UI improvements (#8689)
* feat: restore bun as default runtime for dedicated workers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add context comment for bun dedicated worker nodejs migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dedicated worker dispatch for flows + add E2E tests

- Add workspace_id prefix to dedicated worker map lookup keys
- Update ee-repo-ref for dedicated worker path handling fix
- Add spawn_test_worker_dedicated/in_test_worker_dedicated test helpers
- Add 6 E2E tests for dedicated workers:
  - test_dedicated_flow_rawscript (regression for "Script not found" bug)
  - test_dedicated_flow_workspace_script
  - test_dedicated_flow_multiple_steps
  - test_dedicated_standalone_script
  - test_dedicated_runner_group
  - test_dedicated_flow_runners
- Add dedicated_flows.sql fixture with scripts, flows, and worker config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always run dependency job for dedicated worker scripts

When a script with dedicated_worker=true is deployed with a pre-computed
lock (e.g. via wmill sync push), no dependency job was created, so the
dedicated worker never detected the update and kept running the old version.

Now dedicated worker scripts always generate a dependency job regardless
of whether a lock is provided. The dependency job runs on the dedicated
worker and triggers a restart so it picks up the new script version.

Fixes #8638

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use serial_test for dedicated worker tests to avoid WORKER_CONFIG races

Dedicated worker tests need non-default worker tags in the global
WORKER_CONFIG. When run in parallel (CI uses --test-threads=10),
multiple tests clobber each other's config. Use #[serial] to ensure
dedicated worker tests run sequentially.

Also load worker config from DB via load_worker_config() instead of
manually setting WORKER_CONFIG fields, ensuring consistency with the
monitor's reload path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: nodejs dedicated worker script_path shadowing + add multi-language E2E tests

Fix script_path shadowing in bun_executor nodejs branch where the wrapper
file path was passed to handle_dedicated_process instead of the logical
path, causing "Script not found" for all //nodejs dedicated workers.

Add E2E tests for dedicated flows in all supported languages:
- test_dedicated_flow_deno
- test_dedicated_flow_python
- test_dedicated_flow_bunnative (V8 PrewarmedIsolate path)
- test_dedicated_flow_bun_nodejs (//nodejs annotation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify dedicated worker dispatch + add serialization and E2E tests

- Unified lookup: always use {workspace}:{runnable_path} for dedicated
  worker dispatch, replacing the flow_step_id iteration approach
- Added serialization_semaphore parameter to executor start_worker fns
- Added E2E tests: cross-workspace isolation, conflicting flow step IDs,
  preprocessor on dedicated worker
- Added workspace field to RunJob for cross-workspace test support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: cross-workspace workspace dependencies on workers page

Add two new instance-level endpoints to the configs router:
- GET /configs/list_all_workspace_dependencies
- GET /configs/list_all_dedicated_with_deps

Both require devops role and return data across all workspaces,
enabling the workers page to show a consistent view of which
workspace dependencies exist regardless of which workspace the
user is browsing.

Update DedicatedWorkersSelector to use the new cross-workspace
endpoints with fallback to per-workspace calls for non-devops users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to include dedicated worker lookup simplification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: use branch name for ee-repo-ref (CI can't fetch by SHA from non-default branch)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update ee-repo-ref.txt with new reference

* sqlx

* fix: revert serialization semaphore, multi-workspace picker, dep conflict warnings

- Remove serialization_semaphore from executor start_worker signatures
- Remove serialization test and fixtures
- Fix DedicatedWorkersSelector to preserve tags from other workspaces
  when toggling in the picker
- Track workspace deps per-workspace for conflict detection
- Show warning when dep exists in another workspace but not the script's
- Group runner groups per-workspace to prevent cross-workspace merging
- Add workspace to dep badge link URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify exec protocol — execd: for single-script, exec: for runner groups

Add execd:/execd_preprocess: commands to bun/deno/python wrappers for
single-script dedicated workers (no path needed). Runner groups keep
exec:/exec_preprocess: with path for multi-script disambiguation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for execd:/exec: wrapper protocol

Verify generate_multi_script_wrapper produces both execd: (single-script)
and exec: (runner group) protocol handlers, including preprocessor variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update commit reference in ee-repo-ref.txt

* fix: remove beta badge from squash loop, keep tooltip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update protocol tests to use execd: for single-script wrappers

Deno and bun single-script protocol tests now send execd:{args} instead
of exec:{path}:{args}, matching the updated wrapper protocol. Multi-script
(runner group) tests continue to use exec:{path}:{args}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused TEST_SCRIPT_PATH in deno protocol tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review feedback — down migration, push_as workspace, UI improvements

- Use regexp_replace in down migration for positional accuracy
- Fix push_as() to use self.workspace_id instead of hardcoded value
- Remove per-workspace API fallbacks, use cross-workspace endpoints only
- Skip devops-only API calls when user is not devops (disabled prop)
- Fix duplicate key error for cross-workspace runner groups
- Add workspace to RunnerGroup for unique keying
- Reuse tagRow snippet for standalone items with expand/collapse
- Fix picker alignment: remove empty column for non-expandable items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: comprehensive dedicated worker test coverage, fix Python execd_preprocess

- Add Python execd_preprocess: handler (was missing for single-script dedicated workers)
- Add 10 E2E tests: flow+standalone conflict, mixed lang fallback, unsupported lang
  flow runners, python runner group, bun/python/deno/bunnative preprocessors,
  runner group preprocessors, branchone flow
- Add 4 Python unit tests for execd:/execd_preprocess: protocol
- Update EE ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review feedback — migration escaping, deno try/catch, loadRunnables guard

- Down migration: use E'...' so \n matches actual newlines
- Up migration: anchor regex with ^ to avoid mid-content matches
- Deno execd_preprocess: move JSON.parse inside try/catch
- DedicatedWorkersSelector: skip devops-only API calls when disabled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add dedicated worker relative import tests for bun and python

Verifies that build_loader's CURRENT_PATH correctly resolves workspace-
relative imports when running on a dedicated worker subprocess.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dedicated worker dispatch for nested flow structures (branches/loops)

- Add extract_flow_root() to strip nesting segments from runnable_path
- Dispatch uses flow_root/flow_step_id for nested paths, runnable_path
  for flat paths — deterministic, O(1)
- Fix assert_ran_on_dedicated_worker to BFS all descendants
- Fix python mode labels (python vs python3 for runner groups)
- Add tests: simple forloop, multi-step forloop, whileloop, branchall,
  nested branch-in-loop, mixed lang fallback, unsupported lang runners

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: fix ee-repo-ref SHA

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide picker and skip API calls for read-only users, hide empty runner badge

- Hide "Add more scripts/flows" section when disabled (read-only)
- Skip per-runnable API calls (getScriptByPath, getFlowByPath) for
  disabled users — just show path info
- Hide "0 runners" badge on flows with no eligible steps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 9422b189762ae27edfc346541ae668a4ad728325

This commit updates the EE repository reference after PR #503 was merged in windmill-ee-private.

Previous ee-repo-ref: 4c6ba214bfc23fff05d1dc3200ac59e650af3f4f

New ee-repo-ref: 9422b189762ae27edfc346541ae668a4ad728325

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-03 17:50:07 +00:00

1017 lines
31 KiB
Rust

use serde_json::json;
#[cfg(feature = "python")]
use sqlx::postgres::Postgres;
#[cfg(feature = "python")]
use sqlx::Pool;
#[cfg(feature = "python")]
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
// ============================================================================
// Dedicated Worker Protocol Tests (Python)
// ============================================================================
#[cfg(feature = "python")]
mod dedicated_worker_protocol_python {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry};
struct MultiScriptJob {
script_path: String,
args: serde_json::Value,
}
/// Creates a multi-script Python wrapper, writes scripts to proper module paths
fn create_py_worker_files(
dir: &std::path::Path,
scripts: &[(&str, &str)], // (original_path, content)
) -> std::path::PathBuf {
let mut codegens = Vec::new();
for (path, content) in scripts {
let cg = compute_py_codegen(content, path);
let module_dir = dir.join(&cg.dirs);
std::fs::create_dir_all(&module_dir).unwrap();
std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap();
codegens.push((path.to_string(), cg));
}
let entries: Vec<PyScriptEntry<'_>> = codegens
.iter()
.map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg })
.collect();
let wrapper = generate_py_multi_script_wrapper(&entries, false, false);
let wrapper_path = dir.join("wrapper.py");
std::fs::write(&wrapper_path, &wrapper).unwrap();
wrapper_path
}
fn run_py_multi_script_test(
scripts: &[(&str, &str)],
jobs: Vec<MultiScriptJob>,
) -> Vec<Result<serde_json::Value, String>> {
let temp_dir = tempfile::tempdir().unwrap();
create_py_worker_files(temp_dir.path(), scripts);
let mut child = Command::new("python3")
.args(["-u", "-m", "wrapper"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn python3 process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
"Expected 'start', got: {}",
start_line.trim()
);
let mut results = Vec::new();
for job in &jobs {
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
stdin.flush().unwrap();
let mut response = String::new();
reader.read_line(&mut response).unwrap();
match parse_dedicated_worker_line(response.trim()) {
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
DedicatedWorkerResult::Error(err) => {
let msg = err["message"]
.as_str()
.unwrap_or("Unknown error")
.to_string();
results.push(Err(msg));
}
other => panic!("Unexpected response: {:?}", other),
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
fn run_py_single_script_test(
script_path: &str,
content: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
run_py_multi_script_test(
&[(script_path, content)],
jobs.into_iter()
.map(|args| MultiScriptJob { script_path: script_path.to_string(), args })
.collect(),
)
}
#[test]
fn test_python_dedicated_worker_simple() {
let results = run_py_single_script_test(
"f/test/add",
"def main(a: int, b: int):\n return a + b\n",
vec![serde_json::json!({"a": 3, "b": 4})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(7)));
}
#[test]
fn test_python_dedicated_worker_multiple_jobs() {
let results = run_py_single_script_test(
"f/test/double",
"def main(n: int):\n return n * 2\n",
(1..=5).map(|i| serde_json::json!({"n": i})).collect(),
);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
}
}
#[test]
fn test_python_multi_script_routing() {
let results = run_py_multi_script_test(
&[
(
"f/math/add",
"def main(a: int, b: int):\n return a + b\n",
),
(
"f/math/mul",
"def main(x: int, y: int):\n return x * y\n",
),
],
vec![
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 3, "b": 4}),
},
MultiScriptJob {
script_path: "f/math/mul".to_string(),
args: serde_json::json!({"x": 5, "y": 6}),
},
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 10, "b": 20}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(7)));
assert_eq!(results[1], Ok(serde_json::json!(30)));
assert_eq!(results[2], Ok(serde_json::json!(30)));
}
#[test]
fn test_python_multi_script_error_isolation() {
let results = run_py_multi_script_test(
&[
("f/ok", "def main(x: int):\n return x * 2\n"),
("f/err", "def main(msg: str):\n raise Exception(msg)\n"),
],
vec![
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 5}),
},
MultiScriptJob {
script_path: "f/err".to_string(),
args: serde_json::json!({"msg": "boom"}),
},
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 10}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(10)));
assert!(results[1].is_err());
assert_eq!(results[1], Err("boom".to_string()));
assert_eq!(results[2], Ok(serde_json::json!(20)));
}
#[test]
fn test_python_multi_script_unknown_path() {
let results = run_py_multi_script_test(
&[("f/known", "def main(x: int):\n return x\n")],
vec![MultiScriptJob {
script_path: "f/unknown".to_string(),
args: serde_json::json!({"x": 1}),
}],
);
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert!(results[0]
.as_ref()
.unwrap_err()
.contains("Script not found"));
}
// ==================== exec_preprocess Tests ====================
/// Raw protocol command for Python
enum ProtocolCmd {
Exec { path: String, args: serde_json::Value },
ExecPreprocess { path: String, args: serde_json::Value },
Execd { args: serde_json::Value },
ExecdPreprocess { args: serde_json::Value },
}
/// Run a Python worker test with raw protocol commands
fn run_py_raw_protocol_test(
scripts: &[(&str, &str)],
commands: Vec<ProtocolCmd>,
) -> Vec<DedicatedWorkerResult> {
let temp_dir = tempfile::tempdir().unwrap();
create_py_worker_files(temp_dir.path(), scripts);
let mut child = Command::new("python3")
.args(["-u", "-m", "wrapper"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn python3 process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
);
let mut results = Vec::new();
for cmd in &commands {
let line = match cmd {
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
ProtocolCmd::ExecPreprocess { path, args } => {
format!("exec_preprocess:{}:{}", path, args)
}
ProtocolCmd::Execd { args } => format!("execd:{}", args),
ProtocolCmd::ExecdPreprocess { args } => format!("execd_preprocess:{}", args),
};
writeln!(stdin, "{}", line).unwrap();
stdin.flush().unwrap();
let expected_lines = match cmd {
ProtocolCmd::ExecPreprocess { .. } | ProtocolCmd::ExecdPreprocess { .. } => 2,
ProtocolCmd::Exec { .. } | ProtocolCmd::Execd { .. } => 1,
};
for _ in 0..expected_lines {
let mut response = String::new();
reader.read_line(&mut response).unwrap();
let parsed = parse_dedicated_worker_line(response.trim());
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
results.push(parsed);
break;
}
results.push(parsed);
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_python_exec_preprocess() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 10}
def main(x: int):
return x + 1
"#;
let results = run_py_raw_protocol_test(
&[("f/test/pre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/pre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
assert_eq!(results.len(), 2);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
);
// main(50) => 51
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(51))
);
}
#[test]
fn test_python_exec_preprocess_missing_preprocessor() {
let script = "def main(x: int):\n return x\n";
let results = run_py_raw_protocol_test(
&[("f/test/nopre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/nopre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
assert_eq!(results.len(), 1);
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
}
#[test]
fn test_python_exec_preprocess_then_exec() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 2}
def main(x: int):
return x + 100
"#;
let results = run_py_raw_protocol_test(
&[("f/test/mixed", script)],
vec![
ProtocolCmd::ExecPreprocess {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 5}),
},
ProtocolCmd::Exec {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 7}),
},
],
);
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
// exec: main(7) => 107
assert_eq!(results.len(), 3);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
);
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(110))
);
assert_eq!(
results[2],
DedicatedWorkerResult::Success(serde_json::json!(107))
);
}
// ==================== execd / execd_preprocess Tests ====================
#[test]
fn test_python_execd_single_script() {
let results = run_py_raw_protocol_test(
&[(
"f/test/add",
"def main(a: int, b: int):\n return a + b\n",
)],
vec![ProtocolCmd::Execd { args: serde_json::json!({"a": 3, "b": 4}) }],
);
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
DedicatedWorkerResult::Success(serde_json::json!(7))
);
}
#[test]
fn test_python_execd_preprocess() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 10}
def main(x: int):
return x + 1
"#;
let results = run_py_raw_protocol_test(
&[("f/test/pre", script)],
vec![ProtocolCmd::ExecdPreprocess { args: serde_json::json!({"x": 5}) }],
);
assert_eq!(results.len(), 2);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
);
// main(50) => 51
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(51))
);
}
#[test]
fn test_python_execd_preprocess_missing_preprocessor() {
let script = "def main(x: int):\n return x\n";
let results = run_py_raw_protocol_test(
&[("f/test/nopre", script)],
vec![ProtocolCmd::ExecdPreprocess { args: serde_json::json!({"x": 5}) }],
);
assert_eq!(results.len(), 1);
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
}
#[test]
fn test_python_execd_preprocess_then_execd() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 2}
def main(x: int):
return x + 100
"#;
let results = run_py_raw_protocol_test(
&[("f/test/mixed", script)],
vec![
ProtocolCmd::ExecdPreprocess { args: serde_json::json!({"x": 5}) },
ProtocolCmd::Execd { args: serde_json::json!({"x": 7}) },
],
);
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
// execd: main(7) => 107
assert_eq!(results.len(), 3);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
);
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(110))
);
assert_eq!(
results[2],
DedicatedWorkerResult::Success(serde_json::json!(107))
);
}
// ==================== Argument Transformation Tests ====================
#[test]
fn test_python_datetime_arg_transformation() {
let script = r#"
from datetime import datetime
def main(d: datetime):
return d.isoformat()
"#;
let results = run_py_single_script_test(
"f/test/dt",
script,
vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})],
);
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!("2024-01-15T10:30:00+00:00"))
);
}
#[test]
fn test_python_bytes_arg_transformation() {
let script = r#"
def main(data: bytes):
return len(data)
"#;
// base64 of "hello" is "aGVsbG8="
let results = run_py_single_script_test(
"f/test/bytes",
script,
vec![serde_json::json!({"data": "aGVsbG8="})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(5)));
}
#[test]
fn test_python_kwargs_filtering() {
// Test that extra kwargs are filtered out and only declared args are passed
let script = "def main(a: int, b: int):\n return a + b\n";
let results = run_py_single_script_test(
"f/test/kwargs",
script,
vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(3)));
}
#[test]
fn test_python_function_call_sentinel_removal() {
// Test that '<function call>' sentinel values are removed from args
let script = "def main(a: int, b: int = 10):\n return a + b\n";
let results = run_py_single_script_test(
"f/test/sentinel",
script,
vec![serde_json::json!({"a": 5, "b": "<function call>"})],
);
assert_eq!(results.len(), 1);
// b should be removed (sentinel), default 10 used
assert_eq!(results[0], Ok(serde_json::json!(15)));
}
// ==================== Relative Import Tests ====================
#[test]
fn test_python_dedicated_worker_with_relative_import_detection() {
// Test that the wrapper includes 'import loader' when scripts have relative imports
let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n";
let cg = compute_py_codegen(script_with_relative, "f/test/rel");
let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }];
let wrapper = generate_py_multi_script_wrapper(&entries, false, true);
assert!(
wrapper.contains("import loader"),
"wrapper should contain 'import loader' when any_relative_imports=true"
);
// Without relative imports
let script_no_relative = "def main(x: int):\n return x\n";
let cg2 = compute_py_codegen(script_no_relative, "f/test/norel");
let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }];
let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false);
assert!(
!wrapper2.contains("import loader"),
"wrapper should NOT contain 'import loader' when any_relative_imports=false"
);
}
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
async fn test_requirements_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"# py: ==3.11.11
# requirements:
# tiny==0.1.3
import bar
import baz # pin: foo
import baz # repin: fee
import bug # repin: free
def main():
pass
"#
.to_string();
assert_lockfile(
&db,
content,
ScriptLang::Python3,
vec![
"# workspace-dependencies-mode: manual\n# py: 3.11.11",
"tiny==0.1.3",
],
)
.await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
async fn test_extra_requirements_python(db: Pool<Postgres>) -> anyhow::Result<()> {
{
use windmill_common::scripts::ScriptLang;
let content = r#"# py: ==3.11.11
# extra_requirements:
# tiny
import f.system.extra_requirements
import tiny # pin: tiny==0.1.0
import tiny # pin: tiny==0.1.1
import tiny # repin: tiny==0.1.2
def main():
pass
"#
.to_string();
assert_lockfile(
&db,
content,
ScriptLang::Python3,
vec![
"# workspace-dependencies-mode: extra\n# py: 3.11.11",
"bottle==0.13.2",
"tiny==0.1.2",
],
)
.await?;
}
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
async fn test_extra_requirements_python2(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"# py: ==3.11.11
# extra_requirements:
# tiny==0.1.3
import simplejson # pin: simplejson==3.20.1
def main():
pass
"#
.to_string();
assert_lockfile(
&db,
content,
ScriptLang::Python3,
vec![
"# workspace-dependencies-mode: extra\n# py: 3.11.11",
"simplejson==3.20.1",
"tiny==0.1.3",
],
)
.await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
async fn test_pins_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"# py: ==3.11.11
# extra_requirements:
# tiny==0.1.3
# bottle==0.13.2
import f.system.requirements
import f.system.pins
import tiny # repin: tiny==0.1.3
import simplejson
def main():
pass
"#
.to_string();
assert_lockfile(
&db,
content,
ScriptLang::Python3,
vec![
"# workspace-dependencies-mode: extra\n# py: 3.11.11",
"bottle==0.13.2",
"microdot==2.2.0",
"simplejson==3.19.3",
"tiny==0.1.3",
],
)
.await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "multipython"))]
async fn test_multipython_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"# py: <=3.12.2, >=3.12.0
import f.multipython.script1
import f.multipython.aliases
"#
.to_string();
assert_lockfile(&db, content, ScriptLang::Python3, vec!["# py: 3.12.1\n"]).await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "multipython"))]
async fn test_inline_script_metadata_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"# py_select_latest
# /// script
# requires-python = ">3.11,<3.12.3,!=3.12.2"
# dependencies = [
# "tiny==0.1.3",
# ]
# ///
"#
.to_string();
assert_lockfile(
&db,
content,
ScriptLang::Python3,
vec!["# py: 3.12.1", "tiny==0.1.3"],
)
.await?;
Ok(())
}
#[cfg(feature = "python")]
use windmill_common::jobs::JobPayload;
#[cfg(feature = "python")]
use windmill_common::jobs::RawCode;
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_job(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
def main():
return "hello world"
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("hello world"));
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::worker::ROOT_CACHE_DIR;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Shared for all 3.12.*
let path = format!("{}python_3_12/global-site-packages", *ROOT_CACHE_DIR);
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path + "/my_global_site_package_3_12_any.py", "").unwrap();
// 3.12
{
let content = r#"# py: ==3.12
#requirements:
#
import my_global_site_package_3_12_any
def main():
return "hello world"
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("hello world"));
}
// 3.12.1
{
let content = r#"# py: ==3.12.1
#requirements:
#
import my_global_site_package_3_12_any
def main():
return "hello world"
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("hello world"));
}
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_job_heavy_dep(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
import numpy as np
def main():
a = np.arange(15).reshape(3, 5)
return len(a)
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(3));
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_job_with_imports(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
import wmill
def main():
return wmill.get_workspace()
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("test-workspace"));
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "relative_python"))]
async fn test_relative_imports_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"
from f.system.same_folder_script import main as test1
from .same_folder_script import main as test2
from f.system_relative.different_folder_script import main as test3
from ..system_relative.different_folder_script import main as test4
def main():
return [test1(), test2(), test3(), test4()]
"#
.to_string();
run_deployed_relative_imports(&db, content.clone(), ScriptLang::Python3).await?;
run_preview_relative_imports(&db, content, ScriptLang::Python3).await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "relative_python"))]
async fn test_nested_imports_python(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = r#"
from f.system_relative.nested_script import main as test
def main():
return test()
"#
.to_string();
run_deployed_relative_imports(&db, content.clone(), ScriptLang::Python3).await?;
run_preview_relative_imports(&db, content, ScriptLang::Python3).await?;
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_wac_v2_with_args(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
from wmill import task, workflow
@task()
def greet(label: str, count: int) -> str:
return f"hello {label} x{count}"
@workflow
async def main(item: str, qty: int, email: str):
greeting = await greet(item, qty)
return {"item": item, "qty": qty, "email": email, "greeting": greeting}
"#
.to_string();
// WAC requires at least 2 workers (parent + task sub-jobs)
let db = &db;
in_test_worker(
db,
async move {
let job = Box::pin(
RunJob::from(JobPayload::Code(RawCode {
language: ScriptLang::Python3,
content,
..RawCode::default()
}))
.arg("item", json!("widget"))
.arg("qty", json!(5))
.arg("email", json!("test@example.com"))
.run_until_complete(db, false, port),
)
.await;
let result = job.json_result().unwrap();
assert_eq!(result["item"], json!("widget"));
assert_eq!(result["qty"], json!(5));
assert_eq!(result["email"], json!("test@example.com"));
assert_eq!(result["greeting"], json!("hello widget x5"));
},
port,
)
.await;
Ok(())
}