Callable Actions¶
✅ IMPLEMENTED: Callable actions are fully implemented and tested.
Callable actions invoke Python callable objects (functions, methods, coroutines) dynamically with flexible arguments. This enables direct execution of Python code from workflows, including client methods, utility functions, and custom callables.
Configuration¶
[[actions]]
name = "action-name"
type = "callable"
callable = "module.path:function_or_method_name"
args = [1, "string", true] # Optional positional arguments
kwargs = {key = "value"} # Optional keyword arguments
ai_commit = true # Optional, default: true
Fields¶
callable (required)¶
Python import string specifying the callable to invoke. Uses Pydantic's ImportString format.
Type: string (ImportString format: "module.path:callable_name")
Format: "package.module:function" or "package.module.submodule:ClassName.method"
Examples:
# Function from module
callable = "os.path:join"
# Method from class
callable = "imbi_automations.clients.github:GitHub.create_issue"
# Async function
callable = "asyncio:sleep"
# Custom module
callable = "my_package.utils:process_data"
Import Resolution:
- The string before
:is the module path to import - The string after
:is the attribute name to retrieve from the module - Supports nested attributes (e.g.,
Class.method) - Automatically detects and awaits async callables (coroutines)
args (optional)¶
Positional arguments to pass to the callable. Arguments are passed in order.
Type: list[Any]
Default: []
Supports:
- Primitive types (int, float, bool, string)
- Lists and dictionaries
- Jinja2 template strings (automatically rendered)
- Mixed types
Template Rendering:
args = [
42, # Literal integer
"{{ imbi_project.name }}", # Template string (rendered)
true, # Literal boolean
"literal-string" # Literal string (no templates)
]
Important: Positional argument order is preserved. Template strings are rendered before execution.
kwargs (optional)¶
Keyword arguments to pass to the callable.
Type: dict[string, Any]
Default: {}
Supports:
- Same types as
args - Jinja2 template rendering in string values
- Nested structures
Template Rendering:
[actions.kwargs]
project_name = "{{ imbi_project.name }}" # Template (rendered)
project_id = 123 # Literal integer
enabled = true # Literal boolean
config = {nested = "value"} # Nested dict
ai_commit (optional)¶
Whether to use AI-generated commit messages for repository changes made by this action.
Type: boolean
Default: true
Note: Only relevant if the callable makes repository changes and committable = true.
Template Context¶
String arguments support Jinja2 templating with full workflow context:
| Variable | Description | Example |
|---|---|---|
workflow |
Workflow configuration | {{ workflow.slug }} |
imbi_project |
Imbi project data | {{ imbi_project.name }} |
github_repository |
GitHub repository (if present) | {{ github_repository.name }} |
working_directory |
Execution directory path | {{ working_directory }} |
starting_commit |
Initial commit SHA | {{ starting_commit }} |
Template Detection:
- Only
stringvalues are checked for templates - Templates must contain
{{,{%, or{#syntax - Non-string types (int, bool, list, dict) pass through unchanged
- Strings without template syntax are not rendered
Async/Sync Detection¶
The action automatically detects whether the callable is synchronous or asynchronous using asyncio.iscoroutinefunction() and executes accordingly to maintain async safety.
Async callables:
[[actions]]
name = "call-async"
type = "callable"
callable = "my_module:my_async_function"
args = [42, "hello"]
# Automatically awaited with: await action.callable(*args, **kwargs)
Sync callables:
[[actions]]
name = "call-sync"
type = "callable"
callable = "my_module:my_sync_function"
args = [42, "hello"]
# Executed in thread pool: await asyncio.to_thread(callable, *args, **kwargs)
Detection and Execution:
- Detection Method: Uses
asyncio.iscoroutinefunction()to detect async callables - Async Execution: Coroutines are directly awaited:
await callable(*args, **kwargs) - Sync Execution: Regular functions run in thread pool via
asyncio.to_thread()to prevent blocking the event loop - Thread Pool Benefit: Sync callables can perform blocking I/O without freezing async workflows
Recent Fix (commit dc48d25):
- Sync callables now properly use
asyncio.to_thread()instead of direct execution - Prevents blocking the event loop when sync callables perform blocking operations
- Maintains async safety across all callable types
Examples¶
Call Standard Library Function¶
[[actions]]
name = "create-directory"
type = "callable"
callable = "os:makedirs"
args = ["{{ working_directory }}/output"]
kwargs = {exist_ok = true}
committable = false
Call GitHub Client Method¶
[[actions]]
name = "create-github-label"
type = "callable"
callable = "imbi_automations.clients.github:GitHub.create_label"
kwargs = {
name = "automated",
color = "00ff00",
description = "Created by automation"
}
Call Utility Function with Templates¶
[[actions]]
name = "process-project-data"
type = "callable"
callable = "my_utils:process_data"
args = [
"{{ imbi_project.slug }}",
"{{ imbi_project.project_type }}",
"{{ imbi_project.namespace }}"
]
kwargs = {
output_dir = "{{ working_directory }}/processed",
verbose = true
}
Call Method with Mixed Arguments¶
[[actions]]
name = "update-project-fact"
type = "callable"
callable = "imbi_automations.clients.imbi:Imbi.set_project_fact"
args = [
123, # project_id (literal)
"{{ workflow.slug }}", # fact_name (template)
"completed" # fact_value (literal)
]
Call Custom Function¶
[[actions]]
name = "validate-config"
type = "callable"
callable = "validators.config:validate_yaml"
args = ["{{ working_directory }}/repository/config.yaml"]
kwargs = {
schema = "config-schema.json",
strict = true
}
ignore_errors = false
Async Function Call¶
[[actions]]
name = "async-api-call"
type = "callable"
callable = "my_api.client:fetch_data"
args = ["https://api.example.com/data"]
kwargs = {
timeout = 30,
retry = 3
}
# Automatically awaited due to async detection
Advanced Usage¶
Complex Template Expressions¶
[[actions]]
name = "conditional-processing"
type = "callable"
callable = "processors:handle_project"
args = [
"{{ imbi_project.namespace }}/{{ imbi_project.name }}",
"{{ imbi_project.id | int }}",
"{% if imbi_project.id > 100 %}large{% else %}small{% endif %}"
]
Non-String Template Values¶
[[actions]]
name = "structured-data"
type = "callable"
callable = "handlers:process_metadata"
kwargs = {
project_name = "{{ imbi_project.name }}", # Rendered template (string)
project_id = 123, # Literal integer (not rendered)
enabled = true, # Literal boolean (not rendered)
tags = ["api", "production"], # Literal list (not rendered)
metadata = {
env = "prod", # Nested dict (not rendered)
region = "us-east-1"
}
}
Error Handling with ignore_errors¶
[[actions]]
name = "optional-operation"
type = "callable"
callable = "optional_tasks:try_operation"
args = ["{{ imbi_project.slug }}"]
ignore_errors = true # Continue workflow even if callable fails
Conditional Execution¶
[[actions]]
name = "python-only-task"
type = "callable"
callable = "python_utils:analyze_dependencies"
args = ["{{ working_directory }}/repository"]
# Only run for Python projects
[[actions.conditions]]
file_exists = "requirements.txt"
Return Values¶
Important: Callable actions execute for side effects only. Return values are not captured or made available to subsequent actions.
If you need to capture output:
1. Have the callable write to a file in the working directory
2. Use a subsequent file action to read the output
3. Use the data field to pass information between actions (if needed)
Example:
[[actions]]
name = "generate-report"
type = "callable"
callable = "reporters:generate_report"
kwargs = {
project = "{{ imbi_project.slug }}",
output_file = "{{ working_directory }}/report.json"
}
[[actions]]
name = "read-report"
type = "shell"
command = "cat {{ working_directory }}/report.json"
Error Handling¶
Exception Handling¶
All exceptions raised by callables are caught and wrapped in RuntimeError:
# In callable
def my_function():
raise ValueError("Something went wrong")
# In workflow logs
# RuntimeError: Something went wrong
# Caused by: ValueError: Something went wrong
The original exception is preserved via __cause__ for debugging.
Logging¶
Debug logging:
Exception logging:
Ignore Errors¶
[[actions]]
name = "best-effort-task"
type = "callable"
callable = "optional:task"
ignore_errors = true # Continue workflow even if callable raises exception
Integration with Other Actions¶
Sequential Callable Chain¶
[[actions]]
name = "fetch-data"
type = "callable"
callable = "api.client:fetch_project_data"
args = ["{{ imbi_project.id }}"]
[[actions]]
name = "process-data"
type = "callable"
callable = "processors:transform_data"
args = ["{{ working_directory }}/data.json"]
[[actions]]
name = "upload-results"
type = "callable"
callable = "api.client:upload_results"
args = ["{{ working_directory }}/results.json"]
Callable + Shell (Verification)¶
[[actions]]
name = "run-python-script"
type = "callable"
callable = "scripts.migration:run_migration"
kwargs = {db_url = "{{ config.database_url }}"}
[[actions]]
name = "verify-migration"
type = "shell"
command = "python scripts/verify.py"
working_directory = "repository:///"
Callable + File (Data Processing)¶
[[actions]]
name = "generate-config"
type = "callable"
callable = "config_gen:create_config"
kwargs = {
project = "{{ imbi_project.slug }}",
output = "{{ working_directory }}/config.yaml"
}
[[actions]]
name = "copy-to-repo"
type = "file"
command = "copy"
source = "{{ working_directory }}/config.yaml"
destination = "repository:///config/generated.yaml"
Performance Considerations¶
- Import Time: First call imports the module (cached by Python thereafter)
- Execution Time: Depends on callable implementation
- Async Overhead: Minimal for properly async callables (directly awaited)
- Sync Thread Pool Overhead: Minor context switch cost for sync callables via
asyncio.to_thread() - Template Rendering: Only performed for string arguments with template syntax (detected via regex)
- ResourceUrl Resolution: Path resolution performed for each ResourceUrl argument (cached by
pathlib.Path)
Security Considerations¶
- Code Execution: Callables execute with full Python interpreter access
- Import Safety: Only import from trusted modules
- Argument Validation: Callables should validate input arguments
- Error Information: Exception messages may contain sensitive data
Best Practices¶
✅ Do¶
- Use callable actions for Python-native operations
- Validate arguments in your callable implementations
- Use templates for dynamic values
- Document expected callable signatures
- Handle exceptions gracefully in callables
- Write to files for persistent output
❌ Don't¶
- Don't rely on return values (they're not captured)
- Don't use for operations better suited to specialized actions (file, git, github)
- Don't pass sensitive data in literal arguments (use environment variables or config)
- Don't use blocking sync operations in async callables
Implementation Details¶
- Module:
src/imbi_automations/actions/callablea.py(656 lines of implementation + tests) - Model:
src/imbi_automations/models/workflow.py:WorkflowCallableAction - Tests:
tests/actions/test_callable.py(30 comprehensive test cases, full coverage) - Import Format: Uses Pydantic's
ImportStringvalidator for safe dynamic imports - Async Detection: Uses
asyncio.iscoroutinefunction()to detect coroutines - Sync Execution: Uses
asyncio.to_thread(callable, *args, **kwargs)for thread pool execution - Template Rendering: Uses
prompts.render()with Jinja2, only for strings with{{,{%, or{# - Template Detection: Uses
prompts.has_template_syntax()regex check - ResourceUrl Resolution: Uses
utils.resolve_path()for path scheme handling - Error Wrapping: All exceptions wrapped in
RuntimeErrorwith original as__cause__for exception chaining
Migration from Shell Actions¶
If you were using shell actions to run Python code, consider migrating to callable actions:
Before (shell action):
[[actions]]
name = "run-python"
type = "shell"
command = "python -c 'from mymodule import func; func(\"arg1\", \"arg2\")'"
After (callable action):
[[actions]]
name = "run-python"
type = "callable"
callable = "mymodule:func"
args = ["arg1", "arg2"]
Benefits:
- Type safety and validation
- Better error messages
- No shell escaping issues
- Template support for arguments
- Async/await support
- Cleaner syntax