1use etdl_compiler::{CodeGenerator, Diagnostic, GeneratedFile};
21use etdl_parser::ast::{
22 BackoffStrategy as AstBackoffStrategy, ChannelRef, Condition, EtlDocument, EventTree,
23 MessageRef, Node,
24};
25use etdl_parser::asyncapi::AsyncApiRegistry;
26use etdl_parser::ecel;
27use std::collections::BTreeMap;
28
29pub struct PythonCodeGenerator {
30 pub version: String,
31 pub package: Option<String>,
34}
35
36impl Default for PythonCodeGenerator {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl PythonCodeGenerator {
43 pub fn new() -> Self {
44 PythonCodeGenerator {
45 version: env!("CARGO_PKG_VERSION").to_string(),
46 package: None,
47 }
48 }
49
50 fn resolve_package(&self, doc: &EtlDocument) -> String {
51 if let Some(ref p) = self.package {
52 return p.clone();
53 }
54 sanitize_package(&doc.info.domain)
55 }
56}
57
58impl CodeGenerator for PythonCodeGenerator {
59 fn target_name(&self) -> &'static str {
60 "python"
61 }
62
63 fn generate_all(
64 &self,
65 doc: &EtlDocument,
66 fault_tree_probs: &BTreeMap<String, f64>,
67 registry: &AsyncApiRegistry,
68 stem: &str,
69 _diagnostics: &mut Vec<Diagnostic>,
70 ) -> Result<Vec<GeneratedFile>, String> {
71 let package = self.resolve_package(doc);
72 let _ = stem;
73
74 let mut files = runtime_files();
75 files.push(GeneratedFile::new(format!("{package}/__init__.py"), String::new()));
76
77 let message_refs = collect_message_refs(doc);
78 let mut messages_module = String::new();
79 messages_module.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\n\n");
80 messages_module.push_str("Message dataclasses resolved from AsyncAPI schemas (both External and\n");
81 messages_module.push_str("Internal References, via `AsyncApiRegistry.resolve_message` upstream in\n");
82 messages_module.push_str("the compiler) referenced anywhere in this document.\n\"\"\"\n\n");
83 messages_module.push_str("from __future__ import annotations\n\n");
84 messages_module.push_str("from dataclasses import dataclass\n\n");
85 for (type_name, msg_ref) in &message_refs {
86 let value = registry
87 .resolve_message(doc, msg_ref)
88 .map_err(|e| format!("resolving message for '{type_name}': {e}"))?;
89 messages_module.push_str(&generate_message_dataclass(type_name, &value));
90 }
91 if !message_refs.is_empty() {
92 files.push(GeneratedFile::new(
93 format!("{package}/messages.py"),
94 messages_module,
95 ));
96 }
97
98 for tree in doc.event_trees.values() {
99 let tree_id = tree_id_of(doc, tree);
100 let module_name = to_snake_case(&sanitize_ident(tree_id));
101 let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
102 let handler_methods = collect_handler_methods(tree);
103 files.push(GeneratedFile::new(
104 format!("{package}/{module_name}_handlers.py"),
105 generate_handlers_module(&class_name, &handler_methods, !message_refs.is_empty()),
106 ));
107 }
108
109 let workflow = generate_workflow_module(doc, fault_tree_probs, !message_refs.is_empty())?;
110 files.push(GeneratedFile::new(format!("{package}/workflow.py"), workflow));
111
112 Ok(files)
113 }
114}
115
116fn tree_id_of<'a>(doc: &'a EtlDocument, tree: &EventTree) -> &'a str {
117 doc.event_trees
118 .iter()
119 .find(|(_, t)| std::ptr::eq(*t, tree))
120 .map(|(id, _)| id.as_str())
121 .unwrap_or("Tree")
122}
123
124fn runtime_files() -> Vec<GeneratedFile> {
129 vec![
130 GeneratedFile::new("etdl/__init__.py", String::new()),
131 GeneratedFile::new("etdl/runtime/__init__.py", String::new()),
132 GeneratedFile::new("etdl/runtime/errors.py", ERRORS_PY),
133 GeneratedFile::new("etdl/runtime/native.py", NATIVE_PY),
134 GeneratedFile::new("etdl/runtime/branch_monitor.py", BRANCH_MONITOR_PY),
135 GeneratedFile::new("etdl/runtime/retry_policy.py", RETRY_POLICY_PY),
136 GeneratedFile::new("etdl/runtime/condition.py", CONDITION_PY),
137 GeneratedFile::new("etdl/runtime/publisher.py", PUBLISHER_PY),
138 ]
139}
140
141const ERRORS_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY."""
142
143
144class WorkflowError(Exception):
145 """Raised for a retryable operation failure. Analogous to the Rust
146 target's `etdl_core::WorkflowError` and the Java target's
147 `etdl.runtime.WorkflowError`."""
148"#;
149
150const PUBLISHER_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY."""
151
152from abc import ABC, abstractmethod
153from typing import Any
154
155
156class Publisher(ABC):
157 """The stable API boundary generated orchestration code calls into for
158 a `consequence: send` (core spec Section 5.5.3). A developer supplies a
159 concrete subclass (wiring it to a real broker/client and handling
160 serialization however fits their stack); generated code never depends
161 on a specific transport or JSON library. Application integration, not
162 ETDL runtime machinery — unlike `BranchMonitor`/`RetryPolicy`/
163 `Condition`, there is no native binding here: there is no shared
164 "publish" semantics in the Rust runtime to delegate to.
165 """
166
167 @abstractmethod
168 def publish(self, channel: str, payload: Any) -> None:
169 raise NotImplementedError
170"#;
171
172const NATIVE_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
177
178Binds to `libetdl_runtime_ffi` (built from the `etdl-runtime-ffi` Rust
179crate — see `docs/architecture/targets.md`) via the standard-library
180`ctypes` module. No third-party package is required to run generated
181Python — only a built copy of the native library.
182
183Library resolution order:
1841. environment variable `ETDL_RUNTIME_LIBRARY` — an explicit absolute path
185 to the shared library (`libetdl_runtime_ffi.so`/`.dylib`,
186 `etdl_runtime_ffi.dll`).
1872. `ctypes.CDLL(<platform default name>)` — the platform's standard
188 shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH`
189 on macOS, `PATH` on Windows).
190"""
191
192import ctypes
193import os
194import sys
195
196RETRY_CALLBACK_TYPE = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.c_uint32)
197
198# Mirrors etdl-runtime-ffi's ETDL_OK / ETDL_ERR_* / ETDL_RETRY_* constants exactly.
199OK = 0
200ERR_NULL_HANDLE = -1
201ERR_INVALID_ARG = -2
202RETRY_OK = 0
203RETRY_EXHAUSTED = 1
204RETRY_FATAL = 2
205
206
207def _default_library_name() -> str:
208 if sys.platform == "darwin":
209 return "libetdl_runtime_ffi.dylib"
210 if sys.platform == "win32":
211 return "etdl_runtime_ffi.dll"
212 return "libetdl_runtime_ffi.so"
213
214
215def _load() -> ctypes.CDLL:
216 explicit = os.environ.get("ETDL_RUNTIME_LIBRARY")
217 name = _default_library_name()
218 if explicit:
219 return ctypes.CDLL(explicit)
220 try:
221 return ctypes.CDLL(name)
222 except OSError as e:
223 raise OSError(
224 f"could not load the ETDL Rust runtime ('{name}'). Build it with "
225 "`cargo build -p etdl-runtime-ffi --release` and either put its output "
226 "directory on your platform's shared-library search path, or set "
227 f"ETDL_RUNTIME_LIBRARY=<absolute path to {name}> directly. "
228 f"Original error: {e}"
229 ) from e
230
231
232def _configure(lib: ctypes.CDLL) -> None:
233 lib.etdl_runtime_abi_version.restype = ctypes.c_uint32
234 lib.etdl_runtime_abi_version.argtypes = []
235
236 lib.etdl_last_error_message.restype = ctypes.c_void_p
237 lib.etdl_last_error_message.argtypes = []
238
239 lib.etdl_string_free.restype = None
240 lib.etdl_string_free.argtypes = [ctypes.c_void_p]
241
242 lib.etdl_branch_monitor_new.restype = ctypes.c_void_p
243 lib.etdl_branch_monitor_new.argtypes = [ctypes.c_char_p]
244
245 lib.etdl_branch_monitor_free.restype = None
246 lib.etdl_branch_monitor_free.argtypes = [ctypes.c_void_p]
247
248 lib.etdl_branch_monitor_record_branch.restype = ctypes.c_int32
249 lib.etdl_branch_monitor_record_branch.argtypes = [
250 ctypes.c_void_p,
251 ctypes.c_char_p,
252 ctypes.c_double,
253 ]
254
255 lib.etdl_branch_monitor_record_success.restype = ctypes.c_int32
256 lib.etdl_branch_monitor_record_success.argtypes = [
257 ctypes.c_void_p,
258 ctypes.c_char_p,
259 ctypes.c_double,
260 ctypes.c_bool,
261 ]
262
263 lib.etdl_branch_monitor_record_failure.restype = ctypes.c_int32
264 lib.etdl_branch_monitor_record_failure.argtypes = [
265 ctypes.c_void_p,
266 ctypes.c_char_p,
267 ctypes.c_char_p,
268 ctypes.c_double,
269 ctypes.c_bool,
270 ]
271
272 lib.etdl_retry_policy_new.restype = ctypes.c_void_p
273 lib.etdl_retry_policy_new.argtypes = [ctypes.c_uint32, ctypes.c_uint64, ctypes.c_int32]
274
275 lib.etdl_retry_policy_free.restype = None
276 lib.etdl_retry_policy_free.argtypes = [ctypes.c_void_p]
277
278 lib.etdl_retry_policy_delay_ms.restype = ctypes.c_uint64
279 lib.etdl_retry_policy_delay_ms.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
280
281 lib.etdl_retry_policy_execute.restype = ctypes.c_int32
282 lib.etdl_retry_policy_execute.argtypes = [
283 ctypes.c_void_p,
284 RETRY_CALLBACK_TYPE,
285 ctypes.c_void_p,
286 ctypes.POINTER(ctypes.c_uint32),
287 ]
288
289 lib.etdl_condition_matches.restype = ctypes.c_int32
290 lib.etdl_condition_matches.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
291
292 lib.etdl_condition_contains.restype = ctypes.c_int32
293 lib.etdl_condition_contains.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
294
295
296lib = _load()
297_configure(lib)
298
299
300def last_error() -> str:
301 """Reads and frees the calling thread's last native error message."""
302 ptr = lib.etdl_last_error_message()
303 if not ptr:
304 return "(no further detail available from the native runtime)"
305 message = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8", errors="replace")
306 lib.etdl_string_free(ptr)
307 return message
308"#;
309
310const BRANCH_MONITOR_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
315
316Thin facade over the native `etdl_core::BranchMonitor` (via
317`etdl-runtime-ffi`). Owns one native handle; always use as a context
318manager (generated orchestration code always does) so
319`etdl_branch_monitor_free` runs deterministically.
320"""
321
322from __future__ import annotations
323
324from . import native
325
326
327class BranchMonitor:
328 def __init__(self, node_id: str) -> None:
329 self._handle = native.lib.etdl_branch_monitor_new(node_id.encode("utf-8"))
330 if not self._handle:
331 raise RuntimeError(f"etdl_branch_monitor_new failed: {native.last_error()}")
332 self._closed = False
333
334 def record_branch(self, outcome: str, probability: float) -> None:
335 rc = native.lib.etdl_branch_monitor_record_branch(
336 self._handle, outcome.encode("utf-8"), probability
337 )
338 self._check(rc, "etdl_branch_monitor_record_branch")
339
340 def record_success(self, operation_id: str, probability: float | None) -> None:
341 rc = native.lib.etdl_branch_monitor_record_success(
342 self._handle,
343 operation_id.encode("utf-8"),
344 probability if probability is not None else 0.0,
345 probability is not None,
346 )
347 self._check(rc, "etdl_branch_monitor_record_success")
348
349 def record_failure(
350 self, operation_id: str, error: BaseException, probability: float | None
351 ) -> None:
352 rc = native.lib.etdl_branch_monitor_record_failure(
353 self._handle,
354 operation_id.encode("utf-8"),
355 str(error).encode("utf-8"),
356 probability if probability is not None else 0.0,
357 probability is not None,
358 )
359 self._check(rc, "etdl_branch_monitor_record_failure")
360
361 def _check(self, rc: int, call: str) -> None:
362 if rc != native.OK:
363 raise RuntimeError(f"{call} failed (code {rc}): {native.last_error()}")
364
365 def close(self) -> None:
366 if not self._closed:
367 self._closed = True
368 native.lib.etdl_branch_monitor_free(self._handle)
369
370 def __enter__(self) -> "BranchMonitor":
371 return self
372
373 def __exit__(self, exc_type, exc, tb) -> None:
374 self.close()
375"#;
376
377const RETRY_POLICY_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
387
388Thin facade over the native `etdl_core::RetryPolicy` (via
389`etdl-runtime-ffi`'s callback-driven `etdl_retry_policy_execute`). Owns one
390native handle; always use as a context manager.
391"""
392
393from __future__ import annotations
394
395import ctypes
396from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
397from typing import Any, Callable
398
399from . import native
400from .errors import WorkflowError
401
402
403class BackoffStrategy:
404 FIXED = 0
405 EXPONENTIAL = 1
406
407
408class RetryPolicy:
409 def __init__(self, max_attempts: int, backoff_ms: int, strategy: int) -> None:
410 self._handle = native.lib.etdl_retry_policy_new(max_attempts, backoff_ms, strategy)
411 if not self._handle:
412 raise RuntimeError(f"etdl_retry_policy_new failed: {native.last_error()}")
413 self._closed = False
414
415 def execute(self, op: Callable[[], Any], timeout_s: float | None = None) -> Any:
416 """`op` is a zero-argument callable. May raise `WorkflowError`
417 (retryable) or any other exception (fatal, not retried) — either
418 way, the exception is captured here and never crosses the native
419 call (ctypes callbacks cannot safely propagate a Python exception
420 into C); it is re-raised by this method afterward instead."""
421 result_box: dict[str, Any] = {}
422
423 def trampoline(_user_data: int, _attempt: int) -> int:
424 with ThreadPoolExecutor(max_workers=1) as executor:
425 future = executor.submit(op)
426 try:
427 result_box["value"] = future.result(timeout=timeout_s)
428 return 0
429 except FutureTimeoutError:
430 result_box["checked_error"] = WorkflowError(
431 f"operation timed out after {timeout_s}s"
432 )
433 return 1
434 except WorkflowError as e:
435 result_box["checked_error"] = e
436 return 1
437 except BaseException as e: # noqa: BLE001 - must never cross the FFI boundary
438 result_box["unchecked_error"] = e
439 return -1
440
441 callback = native.RETRY_CALLBACK_TYPE(trampoline)
442 attempts_used = ctypes.c_uint32(0)
443 rc = native.lib.etdl_retry_policy_execute(
444 self._handle, callback, None, ctypes.byref(attempts_used)
445 )
446
447 if rc == native.RETRY_OK:
448 return result_box.get("value")
449 if "unchecked_error" in result_box:
450 raise result_box["unchecked_error"]
451 if "checked_error" in result_box:
452 raise result_box["checked_error"]
453 kind = "retry failed fatally" if rc == native.RETRY_FATAL else "retry exhausted"
454 raise WorkflowError(f"{kind}: {native.last_error()}")
455
456 def close(self) -> None:
457 if not self._closed:
458 self._closed = True
459 native.lib.etdl_retry_policy_free(self._handle)
460
461 def __enter__(self) -> "RetryPolicy":
462 return self
463
464 def __exit__(self, exc_type, exc, tb) -> None:
465 self.close()
466"#;
467
468const CONDITION_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
474
475ECEL (core spec Section 6) `in`/`matches` operators lower to these
476helpers, delegating to the native `etdl_core::condition` module (via
477`etdl-runtime-ffi`) — so a `matches` regex or an `in` membership check
478behaves byte-for-byte identically no matter which target (Rust, Java,
479Python, Go, .NET) evaluates it.
480"""
481
482from __future__ import annotations
483
484import json
485from typing import Any, Iterable
486
487from . import native
488
489
490def matches(value: str | None, pattern: str) -> bool:
491 rc = native.lib.etdl_condition_matches(
492 (value or "").encode("utf-8"), pattern.encode("utf-8")
493 )
494 if rc < 0:
495 raise ValueError(f"etdl_condition_matches: {native.last_error()}")
496 return rc == 1
497
498
499def contains(haystack: Iterable[Any] | None, needle: Any) -> bool:
500 needle_json = json.dumps(needle).encode("utf-8")
501 haystack_json = json.dumps(list(haystack) if haystack is not None else []).encode("utf-8")
502 rc = native.lib.etdl_condition_contains(needle_json, haystack_json)
503 if rc < 0:
504 raise ValueError(f"etdl_condition_contains: {native.last_error()}")
505 return rc == 1
506"#;
507
508fn collect_message_refs(doc: &EtlDocument) -> BTreeMap<String, MessageRef> {
513 let mut refs: BTreeMap<String, MessageRef> = BTreeMap::new();
514 let note = |m: &MessageRef, refs: &mut BTreeMap<String, MessageRef>| {
515 let name = ref_to_type_name(m);
516 refs.entry(name).or_insert_with(|| m.clone());
517 };
518
519 for tree in doc.event_trees.values() {
520 note(&tree.initiating_event.message, &mut refs);
521 for node in tree.nodes.values() {
522 match node {
523 Node::Operation(op) => {
524 if let Some(ref m) = op.emits {
525 note(m, &mut refs);
526 }
527 }
528 Node::Consequence(cons) => {
529 if let Some(ref m) = cons.message {
530 note(m, &mut refs);
531 }
532 }
533 _ => {}
534 }
535 }
536 }
537
538 if let Some(ref fault_trees) = doc.fault_trees {
539 for ft in fault_trees.values() {
540 if let Some(ref m) = ft.top_event.message {
541 note(m, &mut refs);
542 }
543 for be in ft.basic_events.values() {
544 if let Some(ref m) = be.message {
545 note(m, &mut refs);
546 }
547 }
548 }
549 }
550
551 refs
552}
553
554fn ref_to_type_name(msg_ref: &MessageRef) -> String {
555 let pointer = match msg_ref {
556 MessageRef::External(r) => &r.pointer,
557 MessageRef::Internal(r) => &r.pointer,
558 };
559 extract_last_segment(pointer)
560}
561
562fn extract_last_segment(pointer: &str) -> String {
563 let last = pointer.rsplit('/').next().unwrap_or("Unknown");
564 to_pascal_case(last)
565}
566
567fn generate_message_dataclass(type_name: &str, message: &serde_json::Value) -> String {
568 let payload_type_name = format!("{type_name}Payload");
569 let payload_schema = message.get("payload").cloned().unwrap_or(serde_json::Value::Null);
570
571 let mut nested = String::new();
572 let payload_py_type = schema_to_python_type(&payload_type_name, &payload_schema, &mut nested, false);
573
574 let mut out = String::new();
575 out.push_str(&nested);
576 out.push_str("@dataclass\n");
577 out.push_str(&format!("class {type_name}:\n"));
578 out.push_str(&format!(" payload: {payload_py_type}\n"));
579 out.push_str(" headers: dict[str, object] | None = None\n\n\n");
580 out
581}
582
583fn generate_dataclass_from_schema(name: &str, schema: &serde_json::Value, output: &mut String) {
589 let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
590 return;
591 };
592
593 let required: std::collections::BTreeSet<&str> = schema
594 .get("required")
595 .and_then(|r| r.as_array())
596 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
597 .unwrap_or_default();
598
599 let mut nested = String::new();
600 let mut required_fields = Vec::new();
601 let mut optional_fields = Vec::new();
602
603 for (field_name, field_schema) in properties {
604 let snake = to_snake_case(field_name);
605 let nested_type_name = format!("{name}{}", to_pascal_case(field_name));
606 let is_required = required.contains(field_name.as_str());
607 let py_type = schema_to_python_type(&nested_type_name, field_schema, &mut nested, !is_required);
608 if is_required {
609 required_fields.push(format!(" {snake}: {py_type}\n"));
610 } else {
611 optional_fields.push(format!(" {snake}: {py_type} = None\n"));
612 }
613 }
614
615 output.push_str(&nested);
616 output.push_str("@dataclass\n");
617 output.push_str(&format!("class {name}:\n"));
618 if required_fields.is_empty() && optional_fields.is_empty() {
619 output.push_str(" pass\n");
620 } else {
621 for f in &required_fields {
622 output.push_str(f);
623 }
624 for f in &optional_fields {
625 output.push_str(f);
626 }
627 }
628 output.push_str("\n\n");
629}
630
631fn schema_to_python_type(
632 candidate_name: &str,
633 schema: &serde_json::Value,
634 nested: &mut String,
635 nullable: bool,
636) -> String {
637 let base = match schema.get("type").and_then(|t| t.as_str()) {
638 Some("string") => "str".to_string(),
639 Some("integer") => "int".to_string(),
640 Some("number") => "float".to_string(),
641 Some("boolean") => "bool".to_string(),
642 Some("array") => {
643 let item_type = match schema.get("items") {
644 Some(items_schema) => schema_to_python_type(
645 &format!("{candidate_name}Item"),
646 items_schema,
647 nested,
648 true,
649 ),
650 None => "object".to_string(),
651 };
652 format!("list[{item_type}]")
653 }
654 Some("object") => {
655 if schema.get("properties").is_some() {
656 generate_dataclass_from_schema(candidate_name, schema, nested);
657 candidate_name.to_string()
658 } else {
659 "dict[str, object]".to_string()
660 }
661 }
662 _ => "object".to_string(),
663 };
664 if nullable {
665 format!("{base} | None")
666 } else {
667 base
668 }
669}
670
671fn collect_handler_methods(tree: &EventTree) -> Vec<String> {
676 let mut seen = std::collections::BTreeSet::new();
677 let mut methods = Vec::new();
678 for node in tree.nodes.values() {
679 if let Node::Operation(op) = node {
680 let name = to_snake_case(&op.handler);
681 if seen.insert(name.clone()) {
682 methods.push(name);
683 }
684 }
685 }
686 methods
687}
688
689fn generate_handlers_module(class_name: &str, methods: &[String], has_messages: bool) -> String {
690 let mut out = String::new();
691 out.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\n\n");
692 out.push_str("Implement this class in your own (never regenerated) module; the generated\n");
693 out.push_str("orchestration in workflow.py calls through it and never the other way\n");
694 out.push_str("around.\n\"\"\"\n\n");
695 out.push_str("from __future__ import annotations\n\n");
696 out.push_str("from abc import ABC, abstractmethod\n");
697 out.push_str("from typing import Any\n");
698 if has_messages {
699 out.push_str("\nfrom .messages import * # noqa: F401,F403 - message types used in signatures below\n");
700 }
701 out.push_str(&format!("\n\nclass {class_name}(ABC):\n"));
702 if methods.is_empty() {
703 out.push_str(" pass\n");
704 }
705 for method in methods {
706 out.push_str(" @abstractmethod\n");
707 out.push_str(&format!(" def {method}(self, message: Any) -> Any:\n"));
708 out.push_str(" raise NotImplementedError\n\n");
709 }
710 out
711}
712
713fn generate_workflow_module(
718 doc: &EtlDocument,
719 fault_tree_probs: &BTreeMap<String, f64>,
720 has_messages: bool,
721) -> Result<String, String> {
722 let mut out = String::new();
723 out.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\"\"\"\n\n");
724 out.push_str("from __future__ import annotations\n\n");
725 out.push_str("from etdl.runtime.branch_monitor import BranchMonitor\n");
726 out.push_str("from etdl.runtime.condition import contains, matches\n");
727 out.push_str("from etdl.runtime.errors import WorkflowError\n");
728 out.push_str("from etdl.runtime.publisher import Publisher\n");
729 out.push_str("from etdl.runtime.retry_policy import BackoffStrategy, RetryPolicy\n");
730 if has_messages {
731 out.push_str("\nfrom .messages import * # noqa: F401,F403\n");
732 }
733 for tree in doc.event_trees.values() {
734 let tree_id = tree_id_of(doc, tree);
735 let module_name = to_snake_case(&sanitize_ident(tree_id));
736 let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
737 out.push_str(&format!("from .{module_name}_handlers import {class_name}\n"));
738 }
739 out.push('\n');
740
741 out.push_str(&generate_fault_tree_constants(doc, fault_tree_probs));
742
743 for tree in doc.event_trees.values() {
744 let method = generate_event_tree_function(doc, tree, fault_tree_probs)?;
745 out.push_str(&method);
746 out.push('\n');
747 }
748
749 Ok(out)
750}
751
752fn generate_fault_tree_constants(doc: &EtlDocument, fault_tree_probs: &BTreeMap<String, f64>) -> String {
753 let mut output = String::new();
754 for tree in doc.event_trees.values() {
755 for (node_id, node) in &tree.nodes {
756 if let Node::Operation(op) = node {
757 if let Some(ref ps) = op.on_failure_probability_source {
758 if let Some((ft_id, prob)) = find_fault_tree_prob(ps, fault_tree_probs) {
759 output.push_str(&format!(
760 "# Computed from faultTrees.{ft_id}.topEvent at build time (Section 5.16)\n"
761 ));
762 let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
763 output.push_str(&format!("{const_name}: float = {prob:.6}\n\n"));
764 }
765 }
766 }
767 }
768 }
769 output
770}
771
772fn find_fault_tree_prob(
773 ps: &etdl_parser::ast::InternalRef,
774 fault_tree_probs: &BTreeMap<String, f64>,
775) -> Option<(String, f64)> {
776 let ft_id = extract_ft_id(&ps.pointer);
777 fault_tree_probs.get(&ft_id).map(|&v| (ft_id.clone(), v))
778}
779
780fn extract_ft_id(pointer: &str) -> String {
781 pointer
782 .trim_start_matches("#/faultTrees/")
783 .trim_end_matches("/topEvent")
784 .to_string()
785}
786
787fn generate_event_tree_function(
788 doc: &EtlDocument,
789 tree: &EventTree,
790 fault_tree_probs: &BTreeMap<String, f64>,
791) -> Result<String, String> {
792 let mut out = String::new();
793
794 let tree_id = tree_id_of(doc, tree);
795 let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
796 let function_name = format!("handle_{}", to_snake_case(&sanitize_ident(&tree.initiating_event.id)));
797
798 out.push_str(&format!(
799 "def {function_name}(message, publisher: Publisher, handlers: {class_name}) -> None:\n"
800 ));
801
802 let first_barrier = find_first_barrier(tree);
803 let monitor_name = first_barrier.map(to_snake_case).unwrap_or_else(|| "monitor".to_string());
804 out.push_str(&format!(
805 " with BranchMonitor(\"{}\") as {monitor_name}:\n",
806 first_barrier.unwrap_or("root")
807 ));
808
809 let start_node_id = &tree.initiating_event.next;
810 let body = generate_node_code(doc, tree, start_node_id, 2, fault_tree_probs, &monitor_name)?;
811 out.push_str(&body);
812
813 Ok(out)
814}
815
816fn find_first_barrier(tree: &EventTree) -> Option<&str> {
817 let mut current = &tree.initiating_event.next;
818 loop {
819 match tree.nodes.get(current.as_str()) {
820 Some(Node::Barrier(_)) => return Some(current.as_str()),
821 Some(Node::Operation(op)) => current = &op.next,
822 Some(Node::Consequence(_)) => return None,
823 None => return None,
824 }
825 }
826}
827
828fn generate_node_code(
829 doc: &EtlDocument,
830 tree: &EventTree,
831 node_id: &str,
832 depth: usize,
833 fault_tree_probs: &BTreeMap<String, f64>,
834 monitor_name: &str,
835) -> Result<String, String> {
836 let node = tree
837 .nodes
838 .get(node_id)
839 .ok_or_else(|| format!("node '{node_id}' not found"))?;
840
841 match node {
842 Node::Barrier(barrier) => {
843 generate_barrier_code(tree, node_id, barrier, depth, doc, fault_tree_probs, monitor_name)
844 }
845 Node::Operation(op) => {
846 generate_operation_code(tree, node_id, op, depth, doc, fault_tree_probs, monitor_name)
847 }
848 Node::Consequence(cons) => generate_consequence_code(cons, depth),
849 }
850}
851
852fn generate_barrier_code(
853 tree: &EventTree,
854 node_id: &str,
855 barrier: &etdl_parser::ast::Barrier,
856 depth: usize,
857 doc: &EtlDocument,
858 fault_tree_probs: &BTreeMap<String, f64>,
859 monitor_name: &str,
860) -> Result<String, String> {
861 let indent = " ".repeat(depth);
862 let mut output = String::new();
863
864 for (i, branch) in barrier.branches.iter().enumerate() {
865 let is_default = branch.condition == Condition::Default;
866 let keyword = if i == 0 {
867 "if"
868 } else if is_default {
869 "else"
870 } else {
871 "elif"
872 };
873
874 if is_default && i > 0 {
875 output.push_str(&format!("{indent}else:\n"));
876 } else {
877 let cond = condition_to_python_code(&branch.condition);
878 output.push_str(&format!("{indent}{keyword} {cond}:\n"));
879 }
880
881 emit_branch_record(&format!("{indent} "), monitor_name, branch, node_id, fault_tree_probs, &mut output);
882 let body = generate_node_code(doc, tree, &branch.next, depth + 1, fault_tree_probs, monitor_name)?;
883 output.push_str(&body);
884 }
885
886 Ok(output)
887}
888
889fn emit_branch_record(
890 indent: &str,
891 monitor_name: &str,
892 branch: &etdl_parser::ast::Branch,
893 node_id: &str,
894 fault_tree_probs: &BTreeMap<String, f64>,
895 output: &mut String,
896) {
897 if let Some(p) = get_branch_prob(branch, node_id, fault_tree_probs) {
898 output.push_str(&format!(
899 "{indent}{monitor_name}.record_branch(\"{}\", {p:.6})\n",
900 branch.outcome
901 ));
902 } else {
903 output.push_str(&format!("{indent}pass\n"));
904 }
905}
906
907fn get_branch_prob(
908 branch: &etdl_parser::ast::Branch,
909 _node_id: &str,
910 fault_tree_probs: &BTreeMap<String, f64>,
911) -> Option<f64> {
912 if let Some(ref ps) = branch.probability_source {
913 let ft_id = extract_ft_id(&ps.pointer);
914 return fault_tree_probs.get(&ft_id).copied();
915 }
916 branch.effective_probability()
917}
918
919fn generate_operation_code(
920 tree: &EventTree,
921 node_id: &str,
922 op: &etdl_parser::ast::Operation,
923 depth: usize,
924 doc: &EtlDocument,
925 fault_tree_probs: &BTreeMap<String, f64>,
926 monitor_name: &str,
927) -> Result<String, String> {
928 let indent = " ".repeat(depth);
929 let mut output = String::new();
930
931 let handler_name = to_snake_case(&op.handler);
932 let timeout_s = op.timeout_ms.unwrap_or(5000) as f64 / 1000.0;
933
934 output.push_str(&format!("{indent}try:\n"));
935 if let Some(ref retry) = op.retry_policy {
936 let strategy = match retry.backoff_strategy.as_ref().unwrap_or(&AstBackoffStrategy::Fixed) {
937 AstBackoffStrategy::Exponential => "BackoffStrategy.EXPONENTIAL",
938 AstBackoffStrategy::Fixed => "BackoffStrategy.FIXED",
939 };
940 output.push_str(&format!(
941 "{indent} with RetryPolicy({}, {}, {strategy}) as retry:\n",
942 retry.max_attempts, retry.backoff_ms
943 ));
944 output.push_str(&format!(
945 "{indent} _result = retry.execute(lambda: handlers.{handler_name}(message), timeout_s={timeout_s})\n"
946 ));
947 } else {
948 output.push_str(&format!(
949 "{indent} _result = handlers.{handler_name}(message)\n"
950 ));
951 }
952
953 if op.on_failure.is_some() {
954 if let Some(ref ps) = op.on_failure_probability_source {
955 let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
956 let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
957 let indent2 = " ".repeat(depth + 1 + usize::from(op.retry_policy.is_some()));
958 if prob_exists {
959 output.push_str(&format!(
960 "{indent2}{monitor_name}.record_success(\"{node_id}\", {const_name})\n"
961 ));
962 } else {
963 output.push_str(&format!(
964 "{indent2}{monitor_name}.record_success(\"{node_id}\", None)\n"
965 ));
966 }
967 }
968 }
969
970 let body_indent = depth + 1 + usize::from(op.retry_policy.is_some());
971 let next_node = tree.nodes.get(&op.next);
972 match next_node {
973 Some(Node::Consequence(cons)) => {
974 emit_send(cons, "_result", &" ".repeat(body_indent), &mut output);
975 }
976 Some(_) => {
977 let body = generate_node_code(doc, tree, &op.next, body_indent, fault_tree_probs, monitor_name)?;
978 output.push_str(&body);
979 }
980 None => {
981 output.push_str(&format!("{}pass\n", " ".repeat(body_indent)));
982 }
983 }
984
985 output.push_str(&format!("{indent}except WorkflowError as err:\n"));
986
987 if let Some(ref ps) = op.on_failure_probability_source {
988 let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
989 let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
990 if prob_exists {
991 output.push_str(&format!(
992 "{indent} {monitor_name}.record_failure(\"{node_id}\", err, {const_name})\n"
993 ));
994 } else {
995 output.push_str(&format!(
996 "{indent} {monitor_name}.record_failure(\"{node_id}\", err, None)\n"
997 ));
998 }
999 } else {
1000 output.push_str(&format!(
1001 "{indent} {monitor_name}.record_failure(\"{node_id}\", err, None)\n"
1002 ));
1003 }
1004
1005 if let Some(ref on_failure_id) = op.on_failure {
1006 match tree.nodes.get(on_failure_id) {
1007 Some(Node::Consequence(cons)) => {
1008 emit_send(cons, "message", &format!("{indent} "), &mut output);
1009 }
1010 _ => {
1011 let body = generate_node_code(doc, tree, on_failure_id, depth + 1, fault_tree_probs, monitor_name)?;
1012 output.push_str(&body);
1013 }
1014 }
1015 } else {
1016 output.push_str(&format!("{indent} raise\n"));
1017 }
1018
1019 Ok(output)
1020}
1021
1022fn emit_send(cons: &etdl_parser::ast::Consequence, payload_expr: &str, indent: &str, output: &mut String) {
1023 if let etdl_parser::ast::ConsequenceOperation::Send = cons.consequence_operation {
1024 if let Some(ref channel_ref) = cons.channel {
1025 let channel_name = channel_ref_name(channel_ref);
1026 output.push_str(&format!(
1027 "{indent}publisher.publish(\"{channel_name}\", {payload_expr})\n"
1028 ));
1029 return;
1030 }
1031 }
1032 output.push_str(&format!("{indent}pass\n"));
1033}
1034
1035fn generate_consequence_code(cons: &etdl_parser::ast::Consequence, depth: usize) -> Result<String, String> {
1036 let indent = " ".repeat(depth);
1037 let mut output = String::new();
1038
1039 if let etdl_parser::ast::ConsequenceOperation::Send = cons.consequence_operation {
1040 if let Some(ref channel_ref) = cons.channel {
1041 let channel_name = channel_ref_name(channel_ref);
1042 output.push_str(&format!("{indent}publisher.publish(\"{channel_name}\", message)\n"));
1043 return Ok(output);
1044 }
1045 }
1046 output.push_str(&format!("{indent}pass\n"));
1047 Ok(output)
1048}
1049
1050fn channel_ref_name(channel_ref: &ChannelRef) -> String {
1051 match channel_ref {
1052 ChannelRef::External(ext_ref) => extract_last_segment(&ext_ref.pointer),
1053 ChannelRef::Bare(name) => name.clone(),
1054 }
1055}
1056
1057fn condition_to_python_code(condition: &Condition) -> String {
1062 match condition {
1063 Condition::Default => "True".to_string(),
1064 Condition::Expr(expr) => render_bool_expr(expr),
1065 }
1066}
1067
1068fn render_bool_expr(expr: &ecel::BoolExpr) -> String {
1072 use ecel::BoolExpr as B;
1073 match expr {
1074 B::And(a, b) => format!("({}) and ({})", render_bool_expr(a), render_bool_expr(b)),
1075 B::Or(a, b) => format!("({}) or ({})", render_bool_expr(a), render_bool_expr(b)),
1076 B::Not(a) => format!("not ({})", render_bool_expr(a)),
1077 B::Comparison(cmp) => render_comparison(cmp),
1078 B::Quantifier(q) => render_quantifier(q),
1079 B::Defined(path) => render_defined(path),
1080 }
1081}
1082
1083fn render_comparison(cmp: &ecel::Comparison) -> String {
1084 use ecel::Comparator as C;
1085 match cmp.op {
1086 C::In => {
1087 let left = path_or_literal(&cmp.left);
1088 let right = array_or_literal(&cmp.right);
1089 format!("contains({right}, {left})")
1090 }
1091 C::Matches => {
1092 let left = operand_to_path_expr_or_literal(&cmp.left);
1093 let right = literal_to_val_str(&literal_of(&cmp.right));
1094 format!("matches({left}, {right})")
1095 }
1096 _ => {
1097 let (left_path, has_wildcard) = match as_path(&cmp.left) {
1098 Some(p) => build_path_expression(p),
1099 None => (PathParts { path_prefix: String::new(), remaining_path: String::new() }, false),
1100 };
1101 let right = operand_to_val_str(&cmp.right);
1102 let op = comparator_str(&cmp.op);
1103
1104 if has_wildcard {
1105 format!(
1106 "all(item{} {} {} for item in {})",
1107 left_path.remaining_path, op, right, left_path.path_prefix
1108 )
1109 } else {
1110 let l = if left_path.path_prefix.is_empty() && left_path.remaining_path.is_empty() {
1111 operand_to_val_str(&cmp.left)
1112 } else {
1113 left_path.path_prefix + &left_path.remaining_path
1114 };
1115 let r = if right.is_empty() { operand_to_path_expr(&cmp.right) } else { right };
1116 format!("{l} {op} {r}")
1117 }
1118 }
1119 }
1120}
1121
1122fn render_quantifier(q: &ecel::QuantifierExpr) -> String {
1128 if let Some(path_expr) = as_path(&q.comparison.left) {
1129 if path_has_wildcard(path_expr) && !is_headers_path(path_expr) {
1130 let (parts, _has_wildcard) = build_path_expression(path_expr);
1131 let right = operand_to_val_str(&q.comparison.right);
1132 let op = comparator_str(&q.comparison.op);
1133 let builtin = match q.kind {
1134 ecel::QuantifierKind::Any => "any",
1135 ecel::QuantifierKind::All => "all",
1136 };
1137 return format!(
1138 "{}(item{} {} {} for item in {})",
1139 builtin, parts.remaining_path, op, right, parts.path_prefix
1140 );
1141 }
1142 }
1143 render_comparison(&q.comparison)
1144}
1145
1146fn render_defined(path: &ecel::PathExpr) -> String {
1153 if is_headers_path(path) {
1154 let mut expr = String::from("message.headers");
1155 for seg in path.segments.iter().skip(2) {
1156 let key = match seg {
1157 ecel::PathSegment::Field(name) | ecel::PathSegment::QuotedKey(name) => {
1158 format!("\"{name}\"")
1159 }
1160 ecel::PathSegment::Index(idx) => idx.to_string(),
1161 ecel::PathSegment::Wildcard => continue,
1162 };
1163 expr = format!("({expr} or {{}}).get({key})");
1164 }
1165 format!("(({expr}) is not None)")
1166 } else {
1167 format!("(({}) is not None)", render_path_expr(path))
1168 }
1169}
1170
1171fn path_or_literal(operand: &ecel::Operand) -> String {
1172 match as_path(operand) {
1173 Some(_) => operand_to_path_expr(operand),
1174 None => operand_to_val_str(operand),
1175 }
1176}
1177
1178fn array_or_literal(operand: &ecel::Operand) -> String {
1179 match as_path(operand) {
1180 Some(_) => operand_to_path_expr(operand),
1181 None => operand_to_val_str(operand),
1182 }
1183}
1184
1185fn operand_to_path_expr_or_literal(operand: &ecel::Operand) -> String {
1186 match as_path(operand) {
1187 Some(_) => operand_to_path_expr(operand),
1188 None => operand_to_val_str(operand),
1189 }
1190}
1191
1192fn literal_of(operand: &ecel::Operand) -> ecel::Literal {
1193 match operand {
1194 ecel::Operand::Literal(lit) => lit.clone(),
1195 ecel::Operand::Value(_) => ecel::Literal::String(String::new()),
1196 }
1197}
1198
1199fn as_path(operand: &ecel::Operand) -> Option<&ecel::PathExpr> {
1203 match operand {
1204 ecel::Operand::Value(ecel::ValueExpr::Path(p)) => Some(p),
1205 _ => None,
1206 }
1207}
1208
1209fn path_has_wildcard(path: &ecel::PathExpr) -> bool {
1210 path.segments.iter().any(|s| matches!(s, ecel::PathSegment::Wildcard))
1211}
1212
1213fn is_headers_path(path: &ecel::PathExpr) -> bool {
1217 matches!(path.segments.get(1), Some(ecel::PathSegment::Field(name)) if name == "headers")
1218}
1219
1220struct PathParts {
1221 path_prefix: String,
1222 remaining_path: String,
1223}
1224
1225fn build_path_expression(path_expr: &ecel::PathExpr) -> (PathParts, bool) {
1233 let segments = &path_expr.segments;
1234 let mut pre_wildcard = Vec::new();
1235 let mut post_wildcard = Vec::new();
1236 let mut has_wildcard = false;
1237
1238 for (i, seg) in segments.iter().enumerate() {
1239 if i == 0 {
1240 continue;
1241 }
1242 if has_wildcard {
1243 post_wildcard.push(seg.clone());
1244 } else if matches!(seg, ecel::PathSegment::Wildcard) {
1245 has_wildcard = true;
1246 } else {
1247 pre_wildcard.push(seg.clone());
1248 }
1249 }
1250
1251 let mut prefix = String::from("message");
1252 for seg in &pre_wildcard {
1253 append_segment(&mut prefix, seg);
1254 }
1255
1256 let mut suffix = String::new();
1257 for seg in &post_wildcard {
1258 append_segment(&mut suffix, seg);
1259 }
1260
1261 (
1262 PathParts { path_prefix: prefix, remaining_path: suffix },
1263 has_wildcard,
1264 )
1265}
1266
1267fn append_segment(out: &mut String, seg: &ecel::PathSegment) {
1268 match seg {
1269 ecel::PathSegment::Field(name) => {
1270 out.push('.');
1271 out.push_str(&to_snake_case(name));
1272 }
1273 ecel::PathSegment::Index(idx) => {
1274 out.push_str(&format!("[{idx}]"));
1275 }
1276 ecel::PathSegment::QuotedKey(name) => {
1277 out.push_str(&format!("[\"{name}\"]"));
1278 }
1279 _ => {}
1280 }
1281}
1282
1283fn render_path_expr(path: &ecel::PathExpr) -> String {
1289 let mut out = String::from("message");
1290 for seg in path.segments.iter().skip(1) {
1291 if matches!(seg, ecel::PathSegment::Wildcard) {
1292 continue;
1293 }
1294 append_segment(&mut out, seg);
1295 }
1296 out
1297}
1298
1299fn operand_to_path_expr(operand: &ecel::Operand) -> String {
1300 match as_path(operand) {
1301 Some(path) => render_path_expr(path),
1302 None => String::new(),
1303 }
1304}
1305
1306fn operand_to_val_str(operand: &ecel::Operand) -> String {
1307 match operand {
1308 ecel::Operand::Value(ecel::ValueExpr::Path(_)) => String::new(),
1313 ecel::Operand::Value(v) => render_value_expr(v),
1314 ecel::Operand::Literal(lit) => literal_to_val_str(lit),
1315 }
1316}
1317
1318fn render_value_expr(expr: &ecel::ValueExpr) -> String {
1325 use ecel::{FuncName as F, ValueExpr as V};
1326 match expr {
1327 V::Path(path) => render_path_expr(path),
1328 V::Number(n) => n.to_string(),
1329 V::Call(func, arg) => {
1330 let arg_code = render_value_expr(arg);
1331 match func {
1332 F::Length => format!("len({arg_code})"),
1333 F::Abs => format!("abs({arg_code})"),
1334 F::Lower => format!("({arg_code}).lower()"),
1335 F::Upper => format!("({arg_code}).upper()"),
1336 }
1337 }
1338 V::Add(a, b) => format!("(({}) + ({}))", render_value_expr(a), render_value_expr(b)),
1339 V::Sub(a, b) => format!("(({}) - ({}))", render_value_expr(a), render_value_expr(b)),
1340 V::Mul(a, b) => format!("(({}) * ({}))", render_value_expr(a), render_value_expr(b)),
1341 V::Div(a, b) => format!("(({}) / ({}))", render_value_expr(a), render_value_expr(b)),
1342 }
1343}
1344
1345fn literal_to_val_str(lit: &ecel::Literal) -> String {
1346 match lit {
1347 ecel::Literal::Number(n) => n.to_string(),
1348 ecel::Literal::String(s) => format!("\"{s}\""),
1349 ecel::Literal::Bool(b) => if *b { "True".to_string() } else { "False".to_string() },
1350 ecel::Literal::Null => "None".to_string(),
1351 ecel::Literal::Array(items) => {
1352 let inner: Vec<String> = items.iter().map(literal_to_val_str).collect();
1353 format!("[{}]", inner.join(", "))
1354 }
1355 }
1356}
1357
1358fn comparator_str(op: &ecel::Comparator) -> &str {
1359 match op {
1360 ecel::Comparator::Eq => "==",
1361 ecel::Comparator::Neq => "!=",
1362 ecel::Comparator::Gte => ">=",
1363 ecel::Comparator::Lte => "<=",
1364 ecel::Comparator::Gt => ">",
1365 ecel::Comparator::Lt => "<",
1366 ecel::Comparator::In => "in",
1367 ecel::Comparator::Matches => "matches",
1368 }
1369}
1370
1371fn sanitize_package(domain: &str) -> String {
1376 let snake = to_snake_case(domain);
1377 if snake.is_empty() {
1378 "etdl_generated".to_string()
1379 } else {
1380 snake
1381 }
1382}
1383
1384fn sanitize_ident(s: &str) -> String {
1385 let cleaned: String = s
1386 .chars()
1387 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1388 .collect();
1389 if cleaned.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(true) {
1390 format!("_{cleaned}")
1391 } else {
1392 cleaned
1393 }
1394}
1395
1396fn to_snake_case(s: &str) -> String {
1397 let mut result = String::new();
1398 for (i, c) in s.chars().enumerate() {
1399 if c.is_uppercase() {
1400 if i > 0 {
1401 result.push('_');
1402 }
1403 result.push(c.to_lowercase().next().unwrap());
1404 } else if c == '-' || c == ' ' {
1405 result.push('_');
1406 } else {
1407 result.push(c);
1408 }
1409 }
1410 result
1411}
1412
1413fn to_upper_snake(s: &str) -> String {
1414 to_snake_case(s).to_uppercase()
1415}
1416
1417fn to_pascal_case(s: &str) -> String {
1418 let mut result = String::new();
1419 let mut capitalize = true;
1420 for c in s.chars() {
1421 if c == '_' || c == '-' || c == ' ' {
1422 capitalize = true;
1423 } else if capitalize {
1424 result.extend(c.to_uppercase());
1425 capitalize = false;
1426 } else {
1427 result.push(c);
1428 }
1429 }
1430 result
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 #[test]
1438 fn package_snake_cases_domain() {
1439 assert_eq!(sanitize_package("FulfillmentContext"), "fulfillment_context");
1440 assert_eq!(sanitize_package(""), "etdl_generated");
1441 }
1442
1443 #[test]
1444 fn snake_and_pascal_case() {
1445 assert_eq!(to_snake_case("orderId"), "order_id");
1446 assert_eq!(to_pascal_case("order_placed"), "OrderPlaced");
1447 }
1448
1449 #[test]
1450 fn in_operator_lowers_to_native_contains_call() {
1451 let cond = ecel::parse_condition(
1452 "message.payload.status in [\"PAID\", \"AUTHORIZED\"]",
1453 )
1454 .unwrap();
1455 let code = condition_to_python_code(&cond);
1456 assert!(code.contains("contains("), "got: {code}");
1457 assert!(code.contains("\"PAID\""));
1458 }
1459
1460 #[test]
1461 fn matches_operator_lowers_to_native_matches_call() {
1462 let cond = ecel::parse_condition(
1463 "message.payload.reference matches \"^ORD-[0-9]{8}$\"",
1464 )
1465 .unwrap();
1466 let code = condition_to_python_code(&cond);
1467 assert!(code.contains("matches("), "got: {code}");
1468 }
1469
1470 #[test]
1471 fn comparison_uses_plain_attribute_access() {
1472 let cond = ecel::parse_condition("message.payload.amount >= 10000").unwrap();
1473 let code = condition_to_python_code(&cond);
1474 assert_eq!(code, "message.payload.amount >= 10000");
1475 }
1476
1477 #[test]
1478 fn wildcard_condition_uses_all_comprehension() {
1479 let cond = ecel::parse_condition("message.payload.items[*].qty > 0").unwrap();
1480 let code = condition_to_python_code(&cond);
1481 assert_eq!(code, "all(item.qty > 0 for item in message.payload.items)");
1482 }
1483
1484 #[test]
1485 fn boolean_and_or_not_combine_comparisons() {
1486 let cond = ecel::parse_condition(
1487 "message.payload.amount >= 10000 && !(message.payload.status == \"VOID\")",
1488 )
1489 .unwrap();
1490 let code = condition_to_python_code(&cond);
1491 assert!(code.contains(" and "), "got: {code}");
1492 assert!(code.contains("not ("), "got: {code}");
1493
1494 let cond = ecel::parse_condition(
1495 "message.payload.amount >= 10000 || message.payload.priority == true",
1496 )
1497 .unwrap();
1498 let code = condition_to_python_code(&cond);
1499 assert!(code.contains(" or "), "got: {code}");
1500 }
1501
1502 #[test]
1503 fn arithmetic_and_function_calls_render_as_python_expressions() {
1504 let cond = ecel::parse_condition("message.payload.amount + 5 > 100").unwrap();
1505 let code = condition_to_python_code(&cond);
1506 assert!(code.contains("message.payload.amount"), "got: {code}");
1507 assert!(code.contains('+'), "got: {code}");
1508
1509 let cond = ecel::parse_condition("abs(message.payload.amount) > 100").unwrap();
1510 let code = condition_to_python_code(&cond);
1511 assert!(code.contains("abs("), "got: {code}");
1512
1513 let cond = ecel::parse_condition("length(message.payload.reference) > 5").unwrap();
1514 let code = condition_to_python_code(&cond);
1515 assert_eq!(code, "len(message.payload.reference) > 5");
1516 }
1517
1518 #[test]
1519 fn quantifier_lowers_to_any_or_all_generator_expression() {
1520 let cond = ecel::parse_condition(
1521 "any(message.payload.items, message.payload.items[*].qty > 0)",
1522 )
1523 .unwrap();
1524 let code = condition_to_python_code(&cond);
1525 assert!(code.starts_with("any("), "got: {code}");
1526
1527 let cond = ecel::parse_condition(
1528 "all(message.payload.items, message.payload.items[*].qty > 0)",
1529 )
1530 .unwrap();
1531 let code = condition_to_python_code(&cond);
1532 assert!(code.starts_with("all("), "got: {code}");
1533 }
1534
1535 #[test]
1536 fn defined_checks_payload_and_headers_paths() {
1537 let cond = ecel::parse_condition("defined(message.payload.reference)").unwrap();
1538 let code = condition_to_python_code(&cond);
1539 assert_eq!(code, "((message.payload.reference) is not None)");
1540
1541 let cond = ecel::parse_condition("defined(message.headers.traceId)").unwrap();
1542 let code = condition_to_python_code(&cond);
1543 assert!(code.contains("message.headers"), "got: {code}");
1544 assert!(code.contains("is not None"), "got: {code}");
1545 }
1546}