refactor: improve flow handling, typing, and logging; update UI and tests

fix: refine nested flow conditionals and ensure router methods and routes are fully parsed
fix: improve docstrings, typing, and logging coverage across all events
feat: update flow.plot feature with new UI enhancements
chore: apply Ruff linting, reorganize imports, and remove deprecated utilities/files
chore: split constants and utils, clean JS comments, and add typing for linters
tests: strengthen test coverage for flow execution paths and router logic
This commit is contained in:
Greyson LaLonde
2025-11-01 02:15:06 +01:00
committed by GitHub
parent 2e9eb8c32d
commit e229ef4e19
27 changed files with 4976 additions and 1629 deletions

View File

@@ -23,7 +23,6 @@ dependencies = [
"chromadb~=1.1.0",
"tokenizers>=0.20.3",
"openpyxl>=3.1.5",
"pyvis>=0.3.2",
# Authentication and Security
"python-dotenv>=1.1.1",
"pyjwt>=2.9.0",

View File

@@ -88,6 +88,7 @@ class EventListener(BaseEventListener):
text_stream = StringIO()
knowledge_retrieval_in_progress = False
knowledge_query_in_progress = False
method_branches: dict[str, Any] = Field(default_factory=dict)
def __new__(cls):
if cls._instance is None:
@@ -101,6 +102,7 @@ class EventListener(BaseEventListener):
self._telemetry = Telemetry()
self._telemetry.set_tracer()
self.execution_spans = {}
self.method_branches = {}
self._initialized = True
self.formatter = ConsoleFormatter(verbose=True)
@@ -263,7 +265,8 @@ class EventListener(BaseEventListener):
@crewai_event_bus.on(FlowCreatedEvent)
def on_flow_created(source, event: FlowCreatedEvent):
self._telemetry.flow_creation_span(event.flow_name)
self.formatter.create_flow_tree(event.flow_name, str(source.flow_id))
tree = self.formatter.create_flow_tree(event.flow_name, str(source.flow_id))
self.formatter.current_flow_tree = tree
@crewai_event_bus.on(FlowStartedEvent)
def on_flow_started(source, event: FlowStartedEvent):
@@ -280,30 +283,36 @@ class EventListener(BaseEventListener):
@crewai_event_bus.on(MethodExecutionStartedEvent)
def on_method_execution_started(source, event: MethodExecutionStartedEvent):
self.formatter.update_method_status(
self.formatter.current_method_branch,
method_branch = self.method_branches.get(event.method_name)
updated_branch = self.formatter.update_method_status(
method_branch,
self.formatter.current_flow_tree,
event.method_name,
"running",
)
self.method_branches[event.method_name] = updated_branch
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_method_execution_finished(source, event: MethodExecutionFinishedEvent):
self.formatter.update_method_status(
self.formatter.current_method_branch,
method_branch = self.method_branches.get(event.method_name)
updated_branch = self.formatter.update_method_status(
method_branch,
self.formatter.current_flow_tree,
event.method_name,
"completed",
)
self.method_branches[event.method_name] = updated_branch
@crewai_event_bus.on(MethodExecutionFailedEvent)
def on_method_execution_failed(source, event: MethodExecutionFailedEvent):
self.formatter.update_method_status(
self.formatter.current_method_branch,
method_branch = self.method_branches.get(event.method_name)
updated_branch = self.formatter.update_method_status(
method_branch,
self.formatter.current_flow_tree,
event.method_name,
"failed",
)
self.method_branches[event.method_name] = updated_branch
# ----------- TOOL USAGE EVENTS -----------

View File

@@ -357,7 +357,14 @@ class ConsoleFormatter:
return flow_tree
def start_flow(self, flow_name: str, flow_id: str) -> Tree | None:
"""Initialize a flow execution tree."""
"""Initialize or update a flow execution tree."""
if self.current_flow_tree is not None:
for child in self.current_flow_tree.children:
if "Starting Flow" in str(child.label):
child.label = Text("🚀 Flow Started", style="green")
break
return self.current_flow_tree
flow_tree = Tree("")
flow_label = Text()
flow_label.append("🌊 Flow: ", style="blue bold")
@@ -436,26 +443,37 @@ class ConsoleFormatter:
prefix, style = "🔄 Running:", "yellow"
elif status == "completed":
prefix, style = "✅ Completed:", "green"
# Update initialization node when a method completes successfully
for child in flow_tree.children:
if "Starting Flow" in str(child.label):
child.label = Text("Flow Method Step", style="white")
break
else:
prefix, style = "❌ Failed:", "red"
# Update initialization node on failure
for child in flow_tree.children:
if "Starting Flow" in str(child.label):
child.label = Text("❌ Flow Step Failed", style="red")
break
if not method_branch:
# Find or create method branch
if method_branch is not None:
if method_branch in flow_tree.children:
method_branch.label = Text(prefix, style=f"{style} bold") + Text(
f" {method_name}", style=style
)
self.print(flow_tree)
self.print()
return method_branch
for branch in flow_tree.children:
if method_name in str(branch.label):
label_str = str(branch.label)
if f" {method_name}" in label_str and (
"Running:" in label_str
or "Completed:" in label_str
or "Failed:" in label_str
):
method_branch = branch
break
if not method_branch:
if method_branch is None:
method_branch = flow_tree.add("")
method_branch.label = Text(prefix, style=f"{style} bold") + Text(
@@ -464,6 +482,7 @@ class ConsoleFormatter:
self.print(flow_tree)
self.print()
return method_branch
def get_llm_tree(self, tool_name: str):

View File

@@ -1,5 +1,25 @@
from crewai.flow.visualization import (
FlowStructure,
build_flow_structure,
print_structure_summary,
structure_to_dict,
visualize_flow_structure,
)
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.persistence import persist
__all__ = ["Flow", "and_", "listen", "or_", "persist", "router", "start"]
__all__ = [
"Flow",
"FlowStructure",
"and_",
"build_flow_structure",
"listen",
"or_",
"persist",
"print_structure_summary",
"router",
"start",
"structure_to_dict",
"visualize_flow_structure",
]

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{{ title }}</title>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js"
integrity="sha512-LnvoEWDFrqGHlHmDD2101OrLcbsfkrzoSpvtSQtxK3RMnRV0eOkhhBN2dXHKRrUU8p2DGRTk35n4O8nWSVe1mQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/dist/vis-network.min.css"
integrity="sha512-WgxfT5LWjfszlPHXRmBWHkV2eceiWTOBvrKCNbdgDYTHrT2AeLCGbF4sZlZw3UMN3WtL0tGUoIAKsu8mllg/XA=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<style type="text/css">
body {
font-family: verdana;
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
#mynetwork {
flex-grow: 1;
width: 100%;
height: 750px;
background-color: #ffffff;
}
.card {
border: none;
}
.legend-container {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background-color: #f8f9fa;
position: fixed; /* Make the legend fixed */
bottom: 0; /* Position it at the bottom */
width: 100%; /* Make it span the full width */
}
.legend-item {
display: flex;
align-items: center;
margin-right: 20px;
}
.legend-color-box {
width: 20px;
height: 20px;
margin-right: 5px;
}
.logo {
height: 50px;
margin-right: 20px;
}
.legend-dashed {
border-bottom: 2px dashed #666666;
width: 20px;
height: 0;
margin-right: 5px;
}
.legend-solid {
border-bottom: 2px solid #666666;
width: 20px;
height: 0;
margin-right: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="card" style="width: 100%">
<div id="mynetwork" class="card-body"></div>
</div>
<div class="legend-container">
<img
src="data:image/svg+xml;base64,{{ logo_svg_base64 }}"
alt="CrewAI logo"
class="logo"
/>
<!-- LEGEND_ITEMS_PLACEHOLDER -->
</div>
</div>
{{ network_content }}
</body>
</html>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,4 @@
from typing import Final, Literal
AND_CONDITION: Final[Literal["AND"]] = "AND"
OR_CONDITION: Final[Literal["OR"]] = "OR"

View File

@@ -1,3 +1,9 @@
"""Core flow execution framework with decorators and state management.
This module provides the Flow class and decorators (@start, @listen, @router)
for building event-driven workflows with conditional execution and routing.
"""
from __future__ import annotations
import asyncio
@@ -9,6 +15,7 @@ import logging
from typing import (
Any,
ClassVar,
Final,
Generic,
Literal,
ParamSpec,
@@ -38,7 +45,7 @@ from crewai.events.types.flow_events import (
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
from crewai.flow.flow_visualizer import plot_flow
from crewai.flow.visualization import build_flow_structure, render_interactive
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
@@ -58,7 +65,11 @@ from crewai.flow.utils import (
is_flow_method_callable,
is_flow_method_name,
is_simple_flow_condition,
_extract_all_methods,
_extract_all_methods_recursive,
_normalize_condition,
)
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.utilities.printer import Printer, PrinterColor
@@ -74,75 +85,35 @@ class FlowState(BaseModel):
)
# type variables with explicit bounds
T = TypeVar("T", bound=dict[str, Any] | BaseModel) # Generic flow state type parameter
StateT = TypeVar(
"StateT", bound=dict[str, Any] | BaseModel
) # State validation type parameter
P = ParamSpec("P") # ParamSpec for preserving function signatures in decorators
R = TypeVar("R") # Generic return type for decorated methods
F = TypeVar("F", bound=Callable[..., Any]) # Function type for decorator preservation
def ensure_state_type(state: Any, expected_type: type[StateT]) -> StateT:
"""Ensure state matches expected type with proper validation.
Args:
state: State instance to validate
expected_type: Expected type for the state
Returns:
Validated state instance
Raises:
TypeError: If state doesn't match expected type
ValueError: If state validation fails
"""
if expected_type is dict:
if not isinstance(state, dict):
raise TypeError(f"Expected dict, got {type(state).__name__}")
return cast(StateT, state)
if isinstance(expected_type, type) and issubclass(expected_type, BaseModel):
if not isinstance(state, expected_type):
raise TypeError(
f"Expected {expected_type.__name__}, got {type(state).__name__}"
)
return state
raise TypeError(f"Invalid expected_type: {expected_type}")
T = TypeVar("T", bound=dict[str, Any] | BaseModel)
P = ParamSpec("P")
R = TypeVar("R")
F = TypeVar("F", bound=Callable[..., Any])
def start(
condition: str | FlowCondition | Callable[..., Any] | None = None,
) -> Callable[[Callable[P, R]], StartMethod[P, R]]:
"""
Marks a method as a flow's starting point.
"""Marks a method as a flow's starting point.
This decorator designates a method as an entry point for the flow execution.
It can optionally specify conditions that trigger the start based on other
method executions.
Parameters
----------
condition : Optional[Union[str, FlowCondition, Callable[..., Any]]], optional
Defines when the start method should execute. Can be:
Args:
condition: Defines when the start method should execute. Can be:
- str: Name of a method that triggers this start
- FlowCondition: Result from or_() or and_(), including nested conditions
- Callable[..., Any]: A method reference that triggers this start
Default is None, meaning unconditional start.
Returns
-------
Callable[[Callable[P, R]], StartMethod[P, R]]
A decorator function that wraps the method as a flow start point
and preserves its signature.
Returns:
A decorator function that wraps the method as a flow start point and preserves its signature.
Raises
------
ValueError
If the condition format is invalid.
Raises:
ValueError: If the condition format is invalid.
Examples
--------
Examples:
>>> @start() # Unconditional start
>>> def begin_flow(self):
... pass
@@ -157,12 +128,20 @@ def start(
"""
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
"""Decorator that wraps a function as a start method.
Args:
func: The function to wrap as a start method.
Returns:
A StartMethod wrapper around the function.
"""
wrapper = StartMethod(func)
if condition is not None:
if is_flow_method_name(condition):
wrapper.__trigger_methods__ = [condition]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
elif is_flow_condition_dict(condition):
if "conditions" in condition:
wrapper.__trigger_condition__ = condition
@@ -177,7 +156,7 @@ def start(
)
elif is_flow_method_callable(condition):
wrapper.__trigger_methods__ = [condition.__name__]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
else:
raise ValueError(
"Condition must be a method, string, or a result of or_() or and_()"
@@ -190,49 +169,45 @@ def start(
def listen(
condition: str | FlowCondition | Callable[..., Any],
) -> Callable[[Callable[P, R]], ListenMethod[P, R]]:
"""
Creates a listener that executes when specified conditions are met.
"""Creates a listener that executes when specified conditions are met.
This decorator sets up a method to execute in response to other method
executions in the flow. It supports both simple and complex triggering
conditions.
Parameters
----------
condition : Union[str, FlowCondition, Callable[..., Any]]
Specifies when the listener should execute. Can be:
- str: Name of a method that triggers this listener
- FlowCondition: Result from or_() or and_(), including nested conditions
- Callable[..., Any]: A method reference that triggers this listener
Args:
condition: Specifies when the listener should execute.
Returns
-------
Callable[[Callable[P, R]], ListenMethod[P, R]]
A decorator function that wraps the method as a listener
and preserves its signature.
Returns:
A decorator function that wraps the method as a flow listener and preserves its signature.
Raises
------
ValueError
If the condition format is invalid.
Raises:
ValueError: If the condition format is invalid.
Examples
--------
>>> @listen("process_data") # Listen to single method
Examples:
>>> @listen("process_data")
>>> def handle_processed_data(self):
... pass
>>> @listen(or_("success", "failure")) # Listen to multiple methods
>>> @listen("method_name")
>>> def handle_completion(self):
... pass
"""
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
"""Decorator that wraps a function as a listener method.
Args:
func: The function to wrap as a listener method.
Returns:
A ListenMethod wrapper around the function.
"""
wrapper = ListenMethod(func)
if is_flow_method_name(condition):
wrapper.__trigger_methods__ = [condition]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
elif is_flow_condition_dict(condition):
if "conditions" in condition:
wrapper.__trigger_condition__ = condition
@@ -247,7 +222,7 @@ def listen(
)
elif is_flow_method_callable(condition):
wrapper.__trigger_methods__ = [condition.__name__]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
else:
raise ValueError(
"Condition must be a method, string, or a result of or_() or and_()"
@@ -260,54 +235,53 @@ def listen(
def router(
condition: str | FlowCondition | Callable[..., Any],
) -> Callable[[Callable[P, R]], RouterMethod[P, R]]:
"""
Creates a routing method that directs flow execution based on conditions.
"""Creates a routing method that directs flow execution based on conditions.
This decorator marks a method as a router, which can dynamically determine
the next steps in the flow based on its return value. Routers are triggered
by specified conditions and can return constants that determine which path
the flow should take.
Parameters
----------
condition : Union[str, FlowCondition, Callable[..., Any]]
Specifies when the router should execute. Can be:
Args:
condition: Specifies when the router should execute. Can be:
- str: Name of a method that triggers this router
- FlowCondition: Result from or_() or and_(), including nested conditions
- Callable[..., Any]: A method reference that triggers this router
Returns
-------
Callable[[Callable[P, R]], RouterMethod[P, R]]
A decorator function that wraps the method as a router
and preserves its signature.
Returns:
A decorator function that wraps the method as a router and preserves its signature.
Raises
------
ValueError
If the condition format is invalid.
Raises:
ValueError: If the condition format is invalid.
Examples
--------
Examples:
>>> @router("check_status")
>>> def route_based_on_status(self):
... if self.state.status == "success":
... return SUCCESS
... return FAILURE
... return "SUCCESS"
... return "FAILURE"
>>> @router(and_("validate", "process"))
>>> def complex_routing(self):
... if all([self.state.valid, self.state.processed]):
... return CONTINUE
... return STOP
... return "CONTINUE"
... return "STOP"
"""
def decorator(func: Callable[P, R]) -> RouterMethod[P, R]:
"""Decorator that wraps a function as a router method.
Args:
func: The function to wrap as a router method.
Returns:
A RouterMethod wrapper around the function.
"""
wrapper = RouterMethod(func)
if is_flow_method_name(condition):
wrapper.__trigger_methods__ = [condition]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
elif is_flow_condition_dict(condition):
if "conditions" in condition:
wrapper.__trigger_condition__ = condition
@@ -322,7 +296,7 @@ def router(
)
elif is_flow_method_callable(condition):
wrapper.__trigger_methods__ = [condition.__name__]
wrapper.__condition_type__ = "OR"
wrapper.__condition_type__ = OR_CONDITION
else:
raise ValueError(
"Condition must be a method, string, or a result of or_() or and_()"
@@ -333,35 +307,22 @@ def router(
def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition:
"""
Combines multiple conditions with OR logic for flow control.
"""Combines multiple conditions with OR logic for flow control.
Creates a condition that is satisfied when any of the specified conditions
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Parameters
----------
*conditions : Union[str, dict[str, Any], Callable[..., Any]]
Variable number of conditions that can be:
- str: Method names
- dict[str, Any]: Existing condition dictionaries (nested conditions)
- Callable[..., Any]: Method references
Args:
conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references.
Returns
-------
dict[str, Any]
A condition dictionary with format:
{"type": "OR", "conditions": list_of_conditions}
where each condition can be a string (method name) or a nested dict
Returns:
A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict
Raises
------
ValueError
If any condition is invalid.
Raises:
ValueError: If condition format is invalid.
Examples
--------
Examples:
>>> @listen(or_("success", "timeout"))
>>> def handle_completion(self):
... pass
@@ -378,39 +339,27 @@ def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition:
processed_conditions.append(condition.__name__)
else:
raise ValueError("Invalid condition in or_()")
return {"type": "OR", "conditions": processed_conditions}
return {"type": OR_CONDITION, "conditions": processed_conditions}
def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition:
"""
Combines multiple conditions with AND logic for flow control.
"""Combines multiple conditions with AND logic for flow control.
Creates a condition that is satisfied only when all specified conditions
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Parameters
----------
*conditions : Union[str, dict[str, Any], Callable[..., Any]]
Variable number of conditions that can be:
- str: Method names
- dict[str, Any]: Existing condition dictionaries (nested conditions)
- Callable[..., Any]: Method references
Args:
*conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references.
Returns
-------
dict[str, Any]
A condition dictionary with format:
{"type": "AND", "conditions": list_of_conditions}
Returns:
A condition dictionary with format {"type": "AND", "conditions": list_of_conditions}
where each condition can be a string (method name) or a nested dict
Raises
------
ValueError
If any condition is invalid.
Raises:
ValueError: If any condition is invalid.
Examples
--------
Examples:
>>> @listen(and_("validated", "processed"))
>>> def handle_complete_data(self):
... pass
@@ -427,59 +376,7 @@ def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition
processed_conditions.append(condition.__name__)
else:
raise ValueError("Invalid condition in and_()")
return {"type": "AND", "conditions": processed_conditions}
def _normalize_condition(
condition: FlowConditions | FlowCondition | FlowMethodName,
) -> FlowCondition:
"""Normalize a condition to standard format with 'conditions' key.
Args:
condition: Can be a string (method name), dict (condition), or list
Returns:
Normalized dict with 'type' and 'conditions' keys
"""
if is_flow_method_name(condition):
return {"type": "OR", "conditions": [condition]}
if is_flow_condition_dict(condition):
if "conditions" in condition:
return condition
if "methods" in condition:
return {"type": condition["type"], "conditions": condition["methods"]}
return condition
if is_flow_condition_list(condition):
return {"type": "OR", "conditions": condition}
raise ValueError(f"Cannot normalize condition: {condition}")
def _extract_all_methods(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[FlowMethodName]:
"""Extract all method names from a condition (including nested).
Args:
condition: Can be a string, dict, or list
Returns:
List of all method names in the condition tree
"""
if is_flow_method_name(condition):
return [condition]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
methods = []
for sub_cond in normalized.get("conditions", []):
methods.extend(_extract_all_methods(sub_cond))
return methods
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods(item))
return methods
return []
return {"type": AND_CONDITION, "conditions": processed_conditions}
class FlowMeta(type):
@@ -515,7 +412,9 @@ class FlowMeta(type):
and attr_value.__trigger_methods__ is not None
):
methods = attr_value.__trigger_methods__
condition_type = getattr(attr_value, "__condition_type__", "OR")
condition_type = getattr(
attr_value, "__condition_type__", OR_CONDITION
)
if (
hasattr(attr_value, "__trigger_condition__")
and attr_value.__trigger_condition__ is not None
@@ -556,7 +455,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
name: str | None = None
tracing: bool | None = False
def __class_getitem__(cls: type[Flow[StateT]], item: type[T]) -> type[Flow[StateT]]:
def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]:
class _FlowGeneric(cls): # type: ignore
_initial_state_t = item
@@ -1037,19 +936,15 @@ class Flow(Generic[T], metaclass=FlowMeta):
detach(flow_token)
async def _execute_start_method(self, start_method_name: FlowMethodName) -> None:
"""
Executes a flow's start method and its triggered listeners.
"""Executes a flow's start method and its triggered listeners.
This internal method handles the execution of methods marked with @start
decorator and manages the subsequent chain of listener executions.
Parameters
----------
start_method_name : str
The name of the start method to execute.
Args:
start_method_name: The name of the start method to execute.
Notes
-----
Note:
- Executes the start method and captures its result
- Triggers execution of any listeners waiting on this start method
- Part of the flow's initialization sequence
@@ -1174,23 +1069,17 @@ class Flow(Generic[T], metaclass=FlowMeta):
async def _execute_listeners(
self, trigger_method: FlowMethodName, result: Any
) -> None:
"""
Executes all listeners and routers triggered by a method completion.
"""Executes all listeners and routers triggered by a method completion.
This internal method manages the execution flow by:
1. First executing all triggered routers sequentially
2. Then executing all triggered listeners in parallel
Parameters
----------
trigger_method : str
The name of the method that triggered these listeners.
result : Any
The result from the triggering method, passed to listeners
that accept parameters.
Args:
trigger_method: The name of the method that triggered these listeners.
result: The result from the triggering method, passed to listeners that accept parameters.
Notes
-----
Note:
- Routers are executed sequentially to maintain flow control
- Each router's result becomes a new trigger_method
- Normal listeners are executed in parallel for efficiency
@@ -1281,16 +1170,16 @@ class Flow(Generic[T], metaclass=FlowMeta):
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
cond_type = normalized.get("type", "OR")
cond_type = normalized.get("type", OR_CONDITION)
sub_conditions = normalized.get("conditions", [])
if cond_type == "OR":
if cond_type == OR_CONDITION:
return any(
self._evaluate_condition(sub_cond, trigger_method, listener_name)
for sub_cond in sub_conditions
)
if cond_type == "AND":
if cond_type == AND_CONDITION:
pending_key = PendingListenerKey(f"{listener_name}:{id(condition)}")
if pending_key not in self._pending_and_listeners:
@@ -1300,7 +1189,20 @@ class Flow(Generic[T], metaclass=FlowMeta):
if trigger_method in self._pending_and_listeners[pending_key]:
self._pending_and_listeners[pending_key].discard(trigger_method)
if not self._pending_and_listeners[pending_key]:
direct_methods_satisfied = not self._pending_and_listeners[pending_key]
nested_conditions_satisfied = all(
(
self._evaluate_condition(
sub_cond, trigger_method, listener_name
)
if is_flow_condition_dict(sub_cond)
else True
)
for sub_cond in sub_conditions
)
if direct_methods_satisfied and nested_conditions_satisfied:
self._pending_and_listeners.pop(pending_key, None)
return True
@@ -1311,27 +1213,19 @@ class Flow(Generic[T], metaclass=FlowMeta):
def _find_triggered_methods(
self, trigger_method: FlowMethodName, router_only: bool
) -> list[FlowMethodName]:
"""
Finds all methods that should be triggered based on conditions.
"""Finds all methods that should be triggered based on conditions.
This internal method evaluates both OR and AND conditions to determine
which methods should be executed next in the flow. Supports nested conditions.
Parameters
----------
trigger_method : str
The name of the method that just completed execution.
router_only : bool
If True, only consider router methods.
If False, only consider non-router methods.
Args:
trigger_method: The name of the method that just completed execution.
router_only: If True, only consider router methods. If False, only consider non-router methods.
Returns
-------
list[str]
Returns:
Names of methods that should be triggered.
Notes
-----
Note:
- Handles both OR and AND conditions, including nested combinations
- Maintains state for AND conditions using _pending_and_listeners
- Separates router and normal listener evaluation
@@ -1350,10 +1244,10 @@ class Flow(Generic[T], metaclass=FlowMeta):
if is_simple_flow_condition(condition_data):
condition_type, methods = condition_data
if condition_type == "OR":
if condition_type == OR_CONDITION:
if trigger_method in methods:
triggered.append(listener_name)
elif condition_type == "AND":
elif condition_type == AND_CONDITION:
pending_key = PendingListenerKey(listener_name)
if pending_key not in self._pending_and_listeners:
self._pending_and_listeners[pending_key] = set(methods)
@@ -1375,33 +1269,23 @@ class Flow(Generic[T], metaclass=FlowMeta):
async def _execute_single_listener(
self, listener_name: FlowMethodName, result: Any
) -> None:
"""
Executes a single listener method with proper event handling.
"""Executes a single listener method with proper event handling.
This internal method manages the execution of an individual listener,
including parameter inspection, event emission, and error handling.
Parameters
----------
listener_name : str
The name of the listener method to execute.
result : Any
The result from the triggering method, which may be passed
to the listener if it accepts parameters.
Args:
listener_name: The name of the listener method to execute.
result: The result from the triggering method, which may be passed to the listener if it accepts parameters.
Notes
-----
Note:
- Inspects method signature to determine if it accepts the trigger result
- Emits events for method execution start and finish
- Handles errors gracefully with detailed logging
- Recursively triggers listeners of this listener
- Supports both parameterized and parameter-less listeners
- Skips execution if method was already completed (e.g., after reload)
Error Handling
-------------
Catches and logs any exceptions during execution, preventing
individual listener failures from breaking the entire flow.
- Catches and logs any exceptions during execution, preventing individual listener failures from breaking the entire flow
"""
if listener_name in self._completed_methods:
if self._is_execution_resuming:
@@ -1460,7 +1344,16 @@ class Flow(Generic[T], metaclass=FlowMeta):
logger.info(message)
logger.warning(message)
def plot(self, filename: str = "crewai_flow") -> None:
def plot(self, filename: str = "crewai_flow.html", show: bool = True) -> str:
"""Create interactive HTML visualization of Flow structure.
Args:
filename: Output HTML filename (default: "crewai_flow.html").
show: Whether to open in browser (default: True).
Returns:
Absolute path to generated HTML file.
"""
crewai_event_bus.emit(
self,
FlowPlotEvent(
@@ -1468,4 +1361,5 @@ class Flow(Generic[T], metaclass=FlowMeta):
flow_name=self.name or self.__class__.__name__,
),
)
plot_flow(self, filename)
structure = build_flow_structure(self)
return render_interactive(structure, filename=filename, show=show)

View File

@@ -1,234 +0,0 @@
# flow_visualizer.py
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Any
from pyvis.network import Network # type: ignore[import-untyped]
from crewai.flow.config import COLORS, NODE_STYLES, NodeStyles
from crewai.flow.html_template_handler import HTMLTemplateHandler
from crewai.flow.legend_generator import generate_legend_items_html, get_legend_items
from crewai.flow.path_utils import safe_path_join
from crewai.flow.utils import calculate_node_levels
from crewai.flow.visualization_utils import (
add_edges,
add_nodes_to_network,
compute_positions,
)
from crewai.utilities.printer import Printer
if TYPE_CHECKING:
from crewai.flow.flow import Flow
_printer = Printer()
class FlowPlot:
"""Handles the creation and rendering of flow visualization diagrams."""
def __init__(self, flow: Flow[Any]) -> None:
"""
Initialize FlowPlot with a flow object.
Parameters
----------
flow : Flow
A Flow instance to visualize.
Raises
------
ValueError
If flow object is invalid or missing required attributes.
"""
self.flow = flow
self.colors = COLORS
self.node_styles: NodeStyles = NODE_STYLES
def plot(self, filename: str) -> None:
"""
Generate and save an HTML visualization of the flow.
Parameters
----------
filename : str
Name of the output file (without extension).
Raises
------
ValueError
If filename is invalid or network generation fails.
IOError
If file operations fail or visualization cannot be generated.
RuntimeError
If network visualization generation fails.
"""
try:
# Initialize network
net = Network(directed=True, height="750px", bgcolor=self.colors["bg"])
# Set options to disable physics
net.set_options(
"""
var options = {
"nodes": {
"font": {
"multi": "html"
}
},
"physics": {
"enabled": false
}
}
"""
)
# Calculate levels for nodes
try:
node_levels = calculate_node_levels(self.flow)
except Exception as e:
raise ValueError(f"Failed to calculate node levels: {e!s}") from e
# Compute positions
try:
node_positions = compute_positions(self.flow, node_levels)
except Exception as e:
raise ValueError(f"Failed to compute node positions: {e!s}") from e
# Add nodes to the network
try:
add_nodes_to_network(net, self.flow, node_positions, self.node_styles)
except Exception as e:
raise RuntimeError(f"Failed to add nodes to network: {e!s}") from e
# Add edges to the network
try:
add_edges(net, self.flow, node_positions, self.colors)
except Exception as e:
raise RuntimeError(f"Failed to add edges to network: {e!s}") from e
# Generate HTML
try:
network_html = net.generate_html()
final_html_content = self._generate_final_html(network_html)
except Exception as e:
raise RuntimeError(
f"Failed to generate network visualization: {e!s}"
) from e
# Save the final HTML content to the file
try:
with open(f"{filename}.html", "w", encoding="utf-8") as f:
f.write(final_html_content)
_printer.print(f"Plot saved as {filename}.html", color="green")
except IOError as e:
raise IOError(
f"Failed to save flow visualization to {filename}.html: {e!s}"
) from e
except (ValueError, RuntimeError, IOError) as e:
raise e
except Exception as e:
raise RuntimeError(
f"Unexpected error during flow visualization: {e!s}"
) from e
finally:
self._cleanup_pyvis_lib(filename)
def _generate_final_html(self, network_html: str) -> str:
"""
Generate the final HTML content with network visualization and legend.
Parameters
----------
network_html : str
HTML content generated by pyvis Network.
Returns
-------
str
Complete HTML content with styling and legend.
Raises
------
IOError
If template or logo files cannot be accessed.
ValueError
If network_html is invalid.
"""
if not network_html:
raise ValueError("Invalid network HTML content")
try:
# Extract just the body content from the generated HTML
current_dir = os.path.dirname(__file__)
template_path = safe_path_join(
"assets", "crewai_flow_visual_template.html", root=current_dir
)
logo_path = safe_path_join("assets", "crewai_logo.svg", root=current_dir)
if not os.path.exists(template_path):
raise IOError(f"Template file not found: {template_path}")
if not os.path.exists(logo_path):
raise IOError(f"Logo file not found: {logo_path}")
html_handler = HTMLTemplateHandler(template_path, logo_path)
network_body = html_handler.extract_body_content(network_html)
# Generate the legend items HTML
legend_items = get_legend_items(self.colors)
legend_items_html = generate_legend_items_html(legend_items)
return html_handler.generate_final_html(network_body, legend_items_html)
except Exception as e:
raise IOError(f"Failed to generate visualization HTML: {e!s}") from e
@staticmethod
def _cleanup_pyvis_lib(filename: str) -> None:
"""
Clean up the generated lib folder from pyvis.
This method safely removes the temporary lib directory created by pyvis
during network visualization generation. The lib folder is created in the
same directory as the output HTML file.
Parameters
----------
filename : str
The output filename (without .html extension) used for the visualization.
"""
try:
import shutil
output_dir = os.path.dirname(os.path.abspath(filename)) or os.getcwd()
lib_folder = os.path.join(output_dir, "lib")
if os.path.exists(lib_folder) and os.path.isdir(lib_folder):
vis_js = os.path.join(lib_folder, "vis-network.min.js")
if os.path.exists(vis_js):
shutil.rmtree(lib_folder)
except Exception as e:
_printer.print(f"Error cleaning up lib folder: {e}", color="red")
def plot_flow(flow: Flow[Any], filename: str = "flow_plot") -> None:
"""
Convenience function to create and save a flow visualization.
Parameters
----------
flow : Flow
Flow instance to visualize.
filename : str, optional
Output filename without extension, by default "flow_plot".
Raises
------
ValueError
If flow object or filename is invalid.
IOError
If file operations fail.
"""
visualizer = FlowPlot(flow)
visualizer.plot(filename)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable, Sequence
import functools
import inspect
import types
from typing import Any, Generic, Literal, ParamSpec, TypeAlias, TypeVar, TypedDict
from typing_extensions import Required, Self
@@ -17,8 +16,6 @@ P = ParamSpec("P")
R = TypeVar("R")
FlowConditionType: TypeAlias = Literal["OR", "AND"]
# Simple flow condition stored as tuple (condition_type, method_list)
SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]]
@@ -26,6 +23,11 @@ class FlowCondition(TypedDict, total=False):
"""Type definition for flow trigger conditions.
This is a recursive structure where conditions can contain nested FlowConditions.
Attributes:
type: The type of the condition.
conditions: A list of conditions types.
methods: A list of methods.
"""
type: Required[FlowConditionType]
@@ -79,8 +81,7 @@ class FlowMethod(Generic[P, R]):
The result of calling the wrapped method.
"""
if self._instance is not None:
bound = types.MethodType(self._meth, self._instance)
return bound(*args, **kwargs)
return self._meth(self._instance, *args, **kwargs)
return self._meth(*args, **kwargs)
def unwrap(self) -> Callable[P, R]:

View File

@@ -1,91 +0,0 @@
"""HTML template processing and generation for flow visualization diagrams."""
import base64
import re
from typing import Any
from crewai.flow.path_utils import validate_path_exists
class HTMLTemplateHandler:
"""Handles HTML template processing and generation for flow visualization diagrams."""
def __init__(self, template_path: str, logo_path: str) -> None:
"""
Initialize HTMLTemplateHandler with validated template and logo paths.
Parameters
----------
template_path : str
Path to the HTML template file.
logo_path : str
Path to the logo image file.
Raises
------
ValueError
If template or logo paths are invalid or files don't exist.
"""
try:
self.template_path = validate_path_exists(template_path, "file")
self.logo_path = validate_path_exists(logo_path, "file")
except ValueError as e:
raise ValueError(f"Invalid template or logo path: {e}") from e
def read_template(self) -> str:
"""Read and return the HTML template file contents."""
with open(self.template_path, "r", encoding="utf-8") as f:
return f.read()
def encode_logo(self) -> str:
"""Convert the logo SVG file to base64 encoded string."""
with open(self.logo_path, "rb") as logo_file:
logo_svg_data = logo_file.read()
return base64.b64encode(logo_svg_data).decode("utf-8")
def extract_body_content(self, html: str) -> str:
"""Extract and return content between body tags from HTML string."""
match = re.search("<body.*?>(.*?)</body>", html, re.DOTALL)
return match.group(1) if match else ""
def generate_legend_items_html(self, legend_items: list[dict[str, Any]]) -> str:
"""Generate HTML markup for the legend items."""
legend_items_html = ""
for item in legend_items:
if "border" in item:
legend_items_html += f"""
<div class="legend-item">
<div class="legend-color-box" style="background-color: {item["color"]}; border: 2px dashed {item["border"]};"></div>
<div>{item["label"]}</div>
</div>
"""
elif item.get("dashed") is not None:
style = "dashed" if item["dashed"] else "solid"
legend_items_html += f"""
<div class="legend-item">
<div class="legend-{style}" style="border-bottom: 2px {style} {item["color"]};"></div>
<div>{item["label"]}</div>
</div>
"""
else:
legend_items_html += f"""
<div class="legend-item">
<div class="legend-color-box" style="background-color: {item["color"]};"></div>
<div>{item["label"]}</div>
</div>
"""
return legend_items_html
def generate_final_html(
self, network_body: str, legend_items_html: str, title: str = "Flow Plot"
) -> str:
"""Combine all components into final HTML document with network visualization."""
html_template = self.read_template()
logo_svg_base64 = self.encode_logo()
return (
html_template.replace("{{ title }}", title)
.replace("{{ network_content }}", network_body)
.replace("{{ logo_svg_base64 }}", logo_svg_base64)
.replace("<!-- LEGEND_ITEMS_PLACEHOLDER -->", legend_items_html)
)

View File

@@ -1,84 +0,0 @@
"""Legend generation for flow visualization diagrams."""
from typing import Any
from crewai.flow.config import FlowColors
def get_legend_items(colors: FlowColors) -> list[dict[str, Any]]:
"""Generate legend items based on flow colors.
Parameters
----------
colors : FlowColors
Dictionary containing color definitions for flow elements.
Returns
-------
list[dict[str, Any]]
List of legend item dictionaries with labels and styling.
"""
return [
{"label": "Start Method", "color": colors["start"]},
{"label": "Method", "color": colors["method"]},
{
"label": "Crew Method",
"color": colors["bg"],
"border": colors["start"],
"dashed": False,
},
{
"label": "Router",
"color": colors["router"],
"border": colors["router_border"],
"dashed": True,
},
{"label": "Trigger", "color": colors["edge"], "dashed": False},
{"label": "AND Trigger", "color": colors["edge"], "dashed": True},
{
"label": "Router Trigger",
"color": colors["router_edge"],
"dashed": True,
},
]
def generate_legend_items_html(legend_items: list[dict[str, Any]]) -> str:
"""Generate HTML markup for legend items.
Parameters
----------
legend_items : list[dict[str, Any]]
List of legend item dictionaries containing labels and styling.
Returns
-------
str
HTML string containing formatted legend items.
"""
legend_items_html = ""
for item in legend_items:
if "border" in item:
style = "dashed" if item["dashed"] else "solid"
legend_items_html += f"""
<div class="legend-item">
<div class="legend-color-box" style="background-color: {item["color"]}; border: 2px {style} {item["border"]}; border-radius: 5px;"></div>
<div>{item["label"]}</div>
</div>
"""
elif item.get("dashed") is not None:
style = "dashed" if item["dashed"] else "solid"
legend_items_html += f"""
<div class="legend-item">
<div class="legend-{style}" style="border-bottom: 2px {style} {item["color"]}; border-radius: 5px;"></div>
<div>{item["label"]}</div>
</div>
"""
else:
legend_items_html += f"""
<div class="legend-item">
<div class="legend-color-box" style="background-color: {item["color"]}; border-radius: 5px;"></div>
<div>{item["label"]}</div>
</div>
"""
return legend_items_html

View File

@@ -1,133 +0,0 @@
"""
Path utilities for secure file operations in CrewAI flow module.
This module provides utilities for secure path handling to prevent directory
traversal attacks and ensure paths remain within allowed boundaries.
"""
from pathlib import Path
def safe_path_join(*parts: str, root: str | Path | None = None) -> str:
"""
Safely join path components and ensure the result is within allowed boundaries.
Parameters
----------
*parts : str
Variable number of path components to join.
root : Union[str, Path, None], optional
Root directory to use as base. If None, uses current working directory.
Returns
-------
str
String representation of the resolved path.
Raises
------
ValueError
If the resulting path would be outside the root directory
or if any path component is invalid.
"""
if not parts:
raise ValueError("No path components provided")
try:
# Convert all parts to strings and clean them
clean_parts = [str(part).strip() for part in parts if part]
if not clean_parts:
raise ValueError("No valid path components provided")
# Establish root directory
root_path = Path(root).resolve() if root else Path.cwd()
# Join and resolve the full path
full_path = Path(root_path, *clean_parts).resolve()
# Check if the resolved path is within root
if not str(full_path).startswith(str(root_path)):
raise ValueError(
f"Invalid path: Potential directory traversal. Path must be within {root_path}"
)
return str(full_path)
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid path components: {e!s}") from e
def validate_path_exists(path: str | Path, file_type: str = "file") -> str:
"""
Validate that a path exists and is of the expected type.
Parameters
----------
path : Union[str, Path]
Path to validate.
file_type : str, optional
Expected type ('file' or 'directory'), by default 'file'.
Returns
-------
str
Validated path as string.
Raises
------
ValueError
If path doesn't exist or is not of expected type.
"""
try:
path_obj = Path(path).resolve()
if not path_obj.exists():
raise ValueError(f"Path does not exist: {path}")
if file_type == "file" and not path_obj.is_file():
raise ValueError(f"Path is not a file: {path}")
if file_type == "directory" and not path_obj.is_dir():
raise ValueError(f"Path is not a directory: {path}")
return str(path_obj)
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid path: {e!s}") from e
def list_files(directory: str | Path, pattern: str = "*") -> list[str]:
"""
Safely list files in a directory matching a pattern.
Parameters
----------
directory : Union[str, Path]
Directory to search in.
pattern : str, optional
Glob pattern to match files against, by default "*".
Returns
-------
List[str]
List of matching file paths.
Raises
------
ValueError
If directory is invalid or inaccessible.
"""
try:
dir_path = Path(directory).resolve()
if not dir_path.is_dir():
raise ValueError(f"Not a directory: {directory}")
return [str(p) for p in dir_path.glob(pattern) if p.is_file()]
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Error listing files: {e!s}") from e

View File

@@ -13,14 +13,17 @@ Example
>>> ancestors = build_ancestor_dict(flow)
"""
from __future__ import annotations
import ast
from collections import defaultdict, deque
import inspect
import textwrap
from typing import Any
from typing import Any, TYPE_CHECKING
from typing_extensions import TypeIs
from crewai.flow.constants import OR_CONDITION, AND_CONDITION
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
@@ -30,6 +33,8 @@ from crewai.flow.flow_wrappers import (
from crewai.flow.types import FlowMethodCallable, FlowMethodName
from crewai.utilities.printer import Printer
if TYPE_CHECKING:
from crewai.flow.flow import Flow
_printer = Printer()
@@ -74,11 +79,22 @@ def get_possible_return_constants(function: Any) -> list[str] | None:
_printer.print(f"Source code:\n{source}", color="yellow")
return None
return_values = set()
dict_definitions = {}
return_values: set[str] = set()
dict_definitions: dict[str, list[str]] = {}
variable_values: dict[str, list[str]] = {}
class DictionaryAssignmentVisitor(ast.NodeVisitor):
def visit_Assign(self, node):
def extract_string_constants(node: ast.expr) -> list[str]:
"""Recursively extract all string constants from an AST node."""
strings: list[str] = []
if isinstance(node, ast.Constant) and isinstance(node.value, str):
strings.append(node.value)
elif isinstance(node, ast.IfExp):
strings.extend(extract_string_constants(node.body))
strings.extend(extract_string_constants(node.orelse))
return strings
class VariableAssignmentVisitor(ast.NodeVisitor):
def visit_Assign(self, node: ast.Assign) -> None:
# Check if this assignment is assigning a dictionary literal to a variable
if isinstance(node.value, ast.Dict) and len(node.targets) == 1:
target = node.targets[0]
@@ -92,29 +108,53 @@ def get_possible_return_constants(function: Any) -> list[str] | None:
]
if dict_values:
dict_definitions[var_name] = dict_values
if len(node.targets) == 1:
target = node.targets[0]
var_name_alt: str | None = None
if isinstance(target, ast.Name):
var_name_alt = target.id
elif isinstance(target, ast.Attribute):
var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}"
if var_name_alt:
strings = extract_string_constants(node.value)
if strings:
variable_values[var_name_alt] = strings
self.generic_visit(node)
class ReturnVisitor(ast.NodeVisitor):
def visit_Return(self, node):
# Direct string return
if isinstance(node.value, ast.Constant) and isinstance(
node.value.value, str
def visit_Return(self, node: ast.Return) -> None:
if (
node.value
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
return_values.add(node.value.value)
# Dictionary-based return, like return paths[result]
elif isinstance(node.value, ast.Subscript):
# Check if we're subscripting a known dictionary variable
elif node.value and isinstance(node.value, ast.Subscript):
if isinstance(node.value.value, ast.Name):
var_name = node.value.value.id
if var_name in dict_definitions:
# Add all possible dictionary values
for v in dict_definitions[var_name]:
var_name_dict = node.value.value.id
if var_name_dict in dict_definitions:
for v in dict_definitions[var_name_dict]:
return_values.add(v)
elif node.value:
var_name_ret: str | None = None
if isinstance(node.value, ast.Name):
var_name_ret = node.value.id
elif isinstance(node.value, ast.Attribute):
var_name_ret = f"{node.value.value.id if isinstance(node.value.value, ast.Name) else '_'}.{node.value.attr}"
if var_name_ret and var_name_ret in variable_values:
for v in variable_values[var_name_ret]:
return_values.add(v)
self.generic_visit(node)
# First pass: identify dictionary assignments
DictionaryAssignmentVisitor().visit(code_ast)
# Second pass: identify returns
def visit_If(self, node: ast.If) -> None:
self.generic_visit(node)
VariableAssignmentVisitor().visit(code_ast)
ReturnVisitor().visit(code_ast)
return list(return_values) if return_values else None
@@ -158,7 +198,15 @@ def calculate_node_levels(flow: Any) -> dict[str, int]:
# Precompute listener dependencies
or_listeners = defaultdict(list)
and_listeners = defaultdict(set)
for listener_name, (condition_type, trigger_methods) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
condition_type, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
condition_type = condition_data.get("type", "OR")
else:
continue
if condition_type == "OR":
for method in trigger_methods:
or_listeners[method].append(listener_name)
@@ -192,9 +240,13 @@ def calculate_node_levels(flow: Any) -> dict[str, int]:
if listener_name not in visited:
queue.append(listener_name)
# Handle router connections
process_router_paths(flow, current, current_level, levels, queue)
max_level = max(levels.values()) if levels else 0
for method_name in flow._methods:
if method_name not in levels:
levels[method_name] = max_level + 1
return levels
@@ -215,8 +267,14 @@ def count_outgoing_edges(flow: Any) -> dict[str, int]:
counts = {}
for method_name in flow._methods:
counts[method_name] = 0
for method_name in flow._listeners:
_, trigger_methods = flow._listeners[method_name]
for condition_data in flow._listeners.values():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
for trigger in trigger_methods:
if trigger in flow._methods:
counts[trigger] += 1
@@ -271,21 +329,34 @@ def dfs_ancestors(
return
visited.add(node)
# Handle regular listeners
for listener_name, (_, trigger_methods) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
if node in trigger_methods:
ancestors[listener_name].add(node)
ancestors[listener_name].update(ancestors[node])
dfs_ancestors(listener_name, ancestors, visited, flow)
# Handle router methods separately
if node in flow._routers:
router_method_name = node
paths = flow._router_paths.get(router_method_name, [])
for path in paths:
for listener_name, (_, trigger_methods) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
# Only propagate the ancestors of the router method, not the router method itself
ancestors[listener_name].update(ancestors[node])
dfs_ancestors(listener_name, ancestors, visited, flow)
@@ -335,19 +406,32 @@ def build_parent_children_dict(flow: Any) -> dict[str, list[str]]:
"""
parent_children: dict[str, list[str]] = {}
# Map listeners to their trigger methods
for listener_name, (_, trigger_methods) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
for trigger in trigger_methods:
if trigger not in parent_children:
parent_children[trigger] = []
if listener_name not in parent_children[trigger]:
parent_children[trigger].append(listener_name)
# Map router methods to their paths and to listeners
for router_method_name, paths in flow._router_paths.items():
for path in paths:
# Map router method to listeners of each path
for listener_name, (_, trigger_methods) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
if router_method_name not in parent_children:
parent_children[router_method_name] = []
@@ -382,17 +466,27 @@ def get_child_index(
return children.index(child)
def process_router_paths(flow, current, current_level, levels, queue):
"""
Handle the router connections for the current node.
"""
def process_router_paths(
flow: Any,
current: str,
current_level: int,
levels: dict[str, int],
queue: deque[str],
) -> None:
"""Handle the router connections for the current node."""
if current in flow._routers:
paths = flow._router_paths.get(current, [])
for path in paths:
for listener_name, (
_condition_type,
trigger_methods,
) in flow._listeners.items():
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_condition_type, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
if (
listener_name not in levels
@@ -413,7 +507,7 @@ def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]:
return isinstance(obj, str)
def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable]:
def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]:
"""Check if the object is a callable flow method.
Args:
@@ -517,3 +611,107 @@ def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
return False
return True
def _extract_all_methods_recursive(
condition: str | FlowCondition | dict[str, Any] | list[Any],
flow: Flow[Any] | None = None,
) -> list[FlowMethodName]:
"""Extract ALL method names from a condition tree recursively.
This function recursively extracts every method name from the entire
condition tree, regardless of nesting. Used for visualization and debugging.
Note: Only extracts actual method names, not router output strings.
If flow is provided, it will filter out strings that are not in flow._methods.
Args:
condition: Can be a string, dict, or list
flow: Optional flow instance to filter out non-method strings
Returns:
List of all method names found in the condition tree
"""
if is_flow_method_name(condition):
if flow is not None:
if condition in flow._methods:
return [condition]
return []
return [condition]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
methods = []
for sub_cond in normalized.get("conditions", []):
methods.extend(_extract_all_methods_recursive(sub_cond, flow))
return methods
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods_recursive(item, flow))
return methods
return []
def _normalize_condition(
condition: FlowConditions | FlowCondition | FlowMethodName,
) -> FlowCondition:
"""Normalize a condition to standard format with 'conditions' key.
Args:
condition: Can be a string (method name), dict (condition), or list
Returns:
Normalized dict with 'type' and 'conditions' keys
"""
if is_flow_method_name(condition):
return {"type": OR_CONDITION, "conditions": [condition]}
if is_flow_condition_dict(condition):
if "conditions" in condition:
return condition
if "methods" in condition:
return {"type": condition["type"], "conditions": condition["methods"]}
return condition
if is_flow_condition_list(condition):
return {"type": OR_CONDITION, "conditions": condition}
raise ValueError(f"Cannot normalize condition: {condition}")
def _extract_all_methods(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[FlowMethodName]:
"""Extract all method names from a condition (including nested).
For AND conditions, this extracts methods that must ALL complete.
For OR conditions nested inside AND, we don't extract their methods
since only one branch of the OR needs to trigger, not all methods.
This function is used for runtime execution logic, where we need to know
which methods must complete for AND conditions. For visualization purposes,
use _extract_all_methods_recursive() instead.
Args:
condition: Can be a string, dict, or list
Returns:
List of all method names in the condition tree that must complete
"""
if is_flow_method_name(condition):
return [condition]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
cond_type = normalized.get("type", OR_CONDITION)
if cond_type == AND_CONDITION:
return [
sub_cond
for sub_cond in normalized.get("conditions", [])
if is_flow_method_name(sub_cond)
]
return []
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods(item))
return methods
return []

View File

@@ -0,0 +1,25 @@
"""Flow structure visualization utilities."""
from crewai.flow.visualization.builder import (
build_flow_structure,
calculate_execution_paths,
print_structure_summary,
structure_to_dict,
)
from crewai.flow.visualization.renderers import render_interactive
from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge
visualize_flow_structure = render_interactive
__all__ = [
"FlowStructure",
"NodeMetadata",
"StructureEdge",
"build_flow_structure",
"calculate_execution_paths",
"print_structure_summary",
"render_interactive",
"structure_to_dict",
"visualize_flow_structure",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<title>CrewAI Flow Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="'{{ css_path }}'" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="'{{ js_path }}'"></script>
</head>
<body>
<!-- Drawer overlay -->
<div id="drawer-overlay"></div>
<!-- Highlight canvas for active nodes/edges above overlay -->
<canvas id="highlight-canvas"></canvas>
<!-- Side drawer -->
<div id="drawer" style="visibility: hidden;">
<div class="drawer-header">
<div class="drawer-title" id="drawer-node-name">Node Details</div>
<div style="display: flex; align-items: center;">
<button class="drawer-open-ide" id="drawer-open-ide" style="display: none;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 2 L12 2 L12 14 L4 14 Z" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 5 L10 5 M6 8 L10 8 M6 11 L10 11" stroke-linecap="round"/>
</svg>
Open in IDE
</button>
<button class="drawer-close" id="drawer-close">×</button>
</div>
</div>
<div class="drawer-content" id="drawer-content"></div>
</div>
<div id="info">
<div style="text-align: center; margin-bottom: 20px;">
<img src="https://cdn.prod.website-files.com/68de1ee6d7c127849807d7a6/68de1ee6d7c127849807d7ef_Logo.svg"
alt="CrewAI Logo"
style="width: 120px; height: auto;">
</div>
<h3>Flow Execution</h3>
<div class="stats">
<p><strong>Nodes:</strong> '{{ dag_nodes_count }}'</p>
<p><strong>Edges:</strong> '{{ dag_edges_count }}'</p>
<p><strong>Topological Paths:</strong> '{{ execution_paths }}'</p>
</div>
<div class="legend">
<div class="legend-title">Node Types</div>
<div class="legend-item">
<div class="legend-color" style="background: '{{ CREWAI_ORANGE }}';"></div>
<span>Start Methods</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: '{{ DARK_GRAY }}'; border: 3px solid '{{ CREWAI_ORANGE }}';"></div>
<span>Router Methods</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: '{{ DARK_GRAY }}';"></div>
<span>Listen Methods</span>
</div>
</div>
<div class="legend">
<div class="legend-title">Edge Types</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2"/>
</svg>
<span>Router Paths</span>
</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ GRAY }}'" stroke-width="2"/>
</svg>
<span>OR Conditions</span>
</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2" stroke-dasharray="5,5"/>
</svg>
<span>AND Conditions</span>
</div>
</div>
<div class="instructions">
<strong>Interactions:</strong><br>
• Drag to pan<br>
• Scroll to zoom<br><br>
<strong>IDE:</strong>
<select id="ide-selector" style="width: 100%; padding: 4px; margin-top: 4px; border-radius: 3px; border: 1px solid #e0e0e0; background: white; font-size: 12px; cursor: pointer; pointer-events: auto; position: relative; z-index: 10;">
<option value="auto">Auto-detect</option>
<option value="pycharm">PyCharm</option>
<option value="vscode">VS Code</option>
<option value="jetbrains">JetBrains (Toolbox)</option>
</select>
</div>
</div>
<!-- Custom navigation controls -->
<div class="nav-controls">
<div class="nav-button" id="theme-toggle" title="Toggle Dark Mode">🌙</div>
<div class="nav-button" id="zoom-in" title="Zoom In">+</div>
<div class="nav-button" id="zoom-out" title="Zoom Out"></div>
<div class="nav-button" id="fit" title="Fit to Screen">⊡</div>
<div class="nav-button" id="export-png" title="Export to PNG">🖼</div>
<div class="nav-button" id="export-pdf" title="Export to PDF">📄</div>
<div class="nav-button" id="export-json" title="Export to JSON">{}</div>
</div>
<div id="network-container">
<div id="network"></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,849 @@
:root {
--bg-primary: '{{ WHITE }}';
--bg-secondary: rgba(255, 255, 255, 0.95);
--bg-drawer: rgba(255, 255, 255, 0.98);
--bg-overlay: rgba(0, 0, 0, 0.3);
--text-primary: '{{ DARK_GRAY }}';
--text-secondary: '{{ GRAY }}';
--border-color: #e0e0e0;
--border-subtle: rgba(102, 102, 102, 0.3);
--grid-color: rgba(102, 102, 102, 0.08);
--shadow-color: rgba(0, 0, 0, 0.1);
--shadow-strong: rgba(0, 0, 0, 0.15);
--edge-label-text: '{{ GRAY }}';
--edge-label-bg: rgba(255, 255, 255, 0.8);
}
[data-theme="dark"] {
--bg-primary: #0d1117;
--bg-secondary: rgba(22, 27, 34, 0.95);
--bg-drawer: rgba(22, 27, 34, 0.98);
--bg-overlay: rgba(0, 0, 0, 0.5);
--text-primary: #e6edf3;
--text-secondary: #7d8590;
--border-color: #30363d;
--border-subtle: rgba(48, 54, 61, 0.5);
--grid-color: rgba(255, 255, 255, 0.05);
--shadow-color: rgba(0, 0, 0, 0.3);
--shadow-strong: rgba(0, 0, 0, 0.5);
--edge-label-text: #c9d1d9;
--edge-label-bg: rgba(22, 27, 34, 0.9);
}
@keyframes dash {
to {
stroke-dashoffset: -30;
}
}
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--bg-primary);
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 20px 20px;
background-position: -1px -1px;
transition: background-color 0.3s ease;
}
#network-container {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 1999;
pointer-events: none;
}
#network {
width: 100%;
height: 100%;
border: none;
background: transparent;
pointer-events: auto;
}
#info {
position: absolute;
top: 20px;
left: 20px;
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px var(--shadow-strong);
max-width: 320px;
border: 1px solid var(--border-color);
z-index: 10000;
pointer-events: auto;
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
h3 {
margin: 0 0 15px 0;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
}
.stats {
margin-bottom: 15px;
}
.stats p {
margin: 5px 0;
color: var(--text-secondary);
font-size: 14px;
transition: color 0.3s ease;
}
.stats strong {
color: var(--text-primary);
transition: color 0.3s ease;
}
.legend {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
transition: border-color 0.3s ease;
}
.legend-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 14px;
transition: color 0.3s ease;
}
.legend-item {
margin: 8px 0;
display: flex;
align-items: center;
}
.legend-color {
width: 24px;
height: 24px;
margin-right: 12px;
border-radius: 3px;
box-sizing: border-box;
}
.legend-item span {
color: var(--text-secondary);
font-size: 13px;
transition: color 0.3s ease;
}
.instructions {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
transition: color 0.3s ease, border-color 0.3s ease;
}
#ide-selector {
pointer-events: auto;
position: relative;
z-index: 10001;
cursor: pointer;
}
.nav-controls {
position: fixed;
top: auto;
left: 20px;
bottom: 20px;
right: auto;
display: grid;
grid-template-columns: repeat(4, 40px);
gap: 8px;
z-index: 10002;
pointer-events: auto;
max-width: 320px;
}
.nav-controls.drawer-open {
}
.nav-button {
width: 40px;
height: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px var(--shadow-color);
font-size: 18px;
color: var(--text-primary);
user-select: none;
pointer-events: auto;
position: relative;
z-index: 10001;
transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.nav-button:hover {
background: var(--border-subtle);
}
#drawer {
position: fixed;
top: 0;
right: -400px;
width: 400px;
height: 100vh;
background: var(--bg-drawer);
box-shadow: -4px 0 12px var(--shadow-strong);
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease, box-shadow 0.3s ease;
z-index: 2000;
overflow-y: auto;
padding: 24px;
}
#drawer.open {
right: 0;
}
#drawer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--bg-overlay);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease;
z-index: 1998;
cursor: pointer;
}
#drawer-overlay.visible {
opacity: 1;
pointer-events: auto;
}
#highlight-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#highlight-canvas.visible {
opacity: 1;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 2px solid '{{ CREWAI_ORANGE }}';
position: relative;
z-index: 2001;
}
.drawer-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
transition: color 0.3s ease;
}
.drawer-close {
background: none;
border: none;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
transition: color 0.3s ease;
}
.drawer-close:hover {
color: '{{ CREWAI_ORANGE }}';
}
.drawer-open-ide {
background: '{{ CREWAI_ORANGE }}';
border: none;
color: white;
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
margin-right: 12px;
position: relative;
z-index: 9999;
pointer-events: auto;
}
.drawer-open-ide:hover {
background: #ff6b61;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 90, 80, 0.3);
}
.drawer-open-ide:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(255, 90, 80, 0.2);
}
.drawer-open-ide svg {
width: 14px;
height: 14px;
}
.drawer-content {
color: '{{ DARK_GRAY }}';
line-height: 1.6;
}
.drawer-section {
margin-bottom: 20px;
}
.drawer-metadata-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0;
margin-bottom: 20px;
position: relative;
}
.drawer-metadata-grid::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: rgba(102,102,102,0.15);
z-index: 1;
}
.drawer-metadata-grid::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: rgba(102,102,102,0.15);
z-index: 1;
}
.drawer-metadata-grid .drawer-section {
padding: 16px;
position: relative;
}
.drawer-metadata-grid .drawer-section:nth-child(4):nth-last-child(1),
.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(2),
.drawer-metadata-grid .drawer-section:nth-child(2):nth-last-child(3),
.drawer-metadata-grid .drawer-section:nth-child(1):nth-last-child(4) {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-top: 8px;
}
.drawer-metadata-grid .drawer-section:nth-child(4):nth-last-child(1) .drawer-section-title,
.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(2) .drawer-section-title,
.drawer-metadata-grid .drawer-section:nth-child(2):nth-last-child(3) .drawer-section-title,
.drawer-metadata-grid .drawer-section:nth-child(1):nth-last-child(4) .drawer-section-title {
margin-bottom: auto;
}
.drawer-metadata-grid .drawer-section:nth-child(4):nth-last-child(1) > *:not(.drawer-section-title),
.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(2) > *:not(.drawer-section-title),
.drawer-metadata-grid .drawer-section:nth-child(2):nth-last-child(3) > *:not(.drawer-section-title),
.drawer-metadata-grid .drawer-section:nth-child(1):nth-last-child(4) > *:not(.drawer-section-title) {
margin-top: auto;
margin-bottom: auto;
align-self: center;
}
.drawer-metadata-grid .drawer-section:nth-child(1):nth-last-child(1) {
grid-row: 1 / 3;
grid-column: 1 / 3;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.drawer-metadata-grid:has(.drawer-section:nth-child(1):nth-last-child(1))::before,
.drawer-metadata-grid:has(.drawer-section:nth-child(1):nth-last-child(1))::after {
display: none;
}
.drawer-metadata-grid:has(.drawer-section:nth-child(2):nth-last-child(1)) {
grid-template-rows: 1fr;
}
.drawer-metadata-grid .drawer-section:nth-child(2):nth-last-child(1),
.drawer-metadata-grid .drawer-section:nth-child(1):nth-last-child(2) {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
justify-content: center;
}
.drawer-metadata-grid:has(.drawer-section:nth-child(2):nth-last-child(1))::after {
display: none;
}
.drawer-metadata-grid .drawer-section:nth-child(3):nth-last-child(1) {
grid-row: 1 / 3;
grid-column: 2;
display: flex;
flex-direction: column;
justify-content: center;
}
.drawer-metadata-grid:has(.drawer-section:nth-child(3):nth-last-child(1))::after {
right: 50%;
}
.drawer-section-title {
font-size: 12px;
text-transform: uppercase;
color: '{{ GRAY }}';
letter-spacing: 0.5px;
margin-bottom: 8px;
font-weight: 600;
}
.drawer-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.drawer-list {
list-style: none;
padding: 0;
margin: 0;
}
.drawer-list li {
padding: 6px 0;
border-bottom: 1px solid rgba(102,102,102,0.1);
}
.drawer-list li:last-child {
border-bottom: none;
}
.drawer-section:has(.drawer-code-link[style*="color: '{{ CREWAI_ORANGE }}'"]) .drawer-list li {
border-bottom: none;
padding: 3px 0;
}
.drawer-metadata-grid .drawer-section:nth-child(3) .drawer-list li {
border-bottom: none;
padding: 3px 0;
}
.drawer-code {
background: rgba(102,102,102,0.08);
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
color: '{{ DARK_GRAY }}';
border: 1px solid rgba(102,102,102,0.12);
}
.drawer-code-link {
background: rgba(102,102,102,0.08);
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
color: '{{ CREWAI_ORANGE }}';
border: 1px solid rgba(255,90,80,0.2);
cursor: pointer;
transition: all 0.2s;
display: inline-block;
}
.drawer-code-link:hover {
background: rgba(255,90,80,0.12);
border-color: '{{ CREWAI_ORANGE }}';
transform: translateX(2px);
}
.code-block-container {
background: transparent;
border: 1px solid #30363d;
overflow-x: auto;
position: relative;
}
.code-copy-button {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #c9d1d9;
padding: 5px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
z-index: 10;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.code-copy-button:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.code-copy-button:active {
background: rgba(255, 255, 255, 0.2);
}
.code-copy-button.copied {
background: rgba(16, 185, 129, 0.2);
border-color: rgba(16, 185, 129, 0.4);
color: #10b981;
}
.code-copy-button svg {
width: 10px;
height: 10px;
position: absolute;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.code-copy-button .copy-icon {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.code-copy-button .check-icon {
opacity: 0;
transform: scale(0.5) rotate(-90deg);
}
.code-copy-button.copied .copy-icon {
opacity: 0;
transform: scale(0.5) rotate(90deg);
}
.code-copy-button.copied .check-icon {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.code-block {
margin: 0;
padding: 0;
overflow-x: auto;
}
.code-block .code-line {
display: block;
white-space: pre;
min-height: 1.6em;
}
.code-block .line-number {
color: #6e7681;
display: inline-block;
padding-left: 10px;
padding-right: 8px;
margin-right: 8px;
text-align: right;
user-select: none;
border-right: 1px solid #30363d;
}
.code-block .json-key {
color: #79c0ff;
font-weight: 400;
}
.code-block .json-bracket {
color: #c9d1d9;
font-weight: 400;
}
.code-block .json-colon {
color: #c9d1d9;
}
.accordion-section {
border: 1px solid rgba(102,102,102,0.15);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #0d1117;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 1px 3px rgba(0, 0, 0, 0.3);
}
.accordion-section:hover {
border-color: rgba(102,102,102,0.25);
}
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
cursor: pointer;
background: rgba(102,102,102,0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.accordion-subheader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
cursor: pointer;
background: #0d1117;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
.accordion-header:hover {
background: rgba(102,102,102,0.1);
}
.accordion-header:active {
background: rgba(102,102,102,0.15);
}
.accordion-title {
font-size: 12px;
font-weight: 400;
text-transform: uppercase;
color: white;
letter-spacing: 0.5px;
font-family: monospace;
}
.accordion-icon {
width: 16px;
height: 16px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: '{{ GRAY }}';
}
.accordion-section.expanded .accordion-icon {
transform: rotate(180deg);
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
padding: 0;
}
.accordion-section.expanded .accordion-content {
max-height: 2000px;
opacity: 1;
padding: 0;
}
.accordion-content .drawer-section {
margin-bottom: 0;
}
.accordion-content .drawer-section:not(:first-child) {
margin-top: 0;
}
.accordion-content .drawer-section-title {
display: none;
}
.accordion-content .class-section-title {
display: block;
font-size: 12px;
text-transform: uppercase;
color: '{{ GRAY }}';
letter-spacing: 0.5px;
margin-bottom: 8px;
font-weight: 600;
}
/*
* Synthwave '84 Theme originally by Robb Owen [@Robb0wen] for Visual Studio Code
* Demo: https://marc.dev/demo/prism-synthwave84
*
* Ported for PrismJS by Marc Backes [@themarcba]
*/
code[class*="language-"],
pre[class*="language-"] {
color: #f92aad;
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 0.7em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.25;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
padding-top: 0.4em;
padding-bottom: 0.4em;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background-color: transparent !important;
background-image: linear-gradient(to bottom, #2a2139 75%, #34294f);
}
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #8e8e8e;
}
.token.punctuation {
color: #ccc;
}
.token.tag,
.token.attr-name,
.token.namespace,
.token.number,
.token.unit,
.token.hexcode,
.token.deleted {
color: #e2777a;
}
.token.property,
.token.selector {
color: #72f1b8;
text-shadow: 0 0 2px #100c0f, 0 0 10px #257c5575, 0 0 35px #21272475;
}
.token.function-name {
color: #6196cc;
}
.token.boolean,
.token.selector .token.id,
.token.function {
color: #fdfdfd;
text-shadow: 0 0 2px #001716, 0 0 3px #03edf975, 0 0 5px #03edf975, 0 0 8px #03edf975;
}
.token.class-name {
color: #fff5f6;
text-shadow: 0 0 2px #000, 0 0 10px #fc1f2c75, 0 0 5px #fc1f2c75, 0 0 25px #fc1f2c75;
}
.token.constant,
.token.symbol {
color: #f92aad;
text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;
}
.token.important,
.token.atrule,
.token.keyword,
.token.selector .token.class,
.token.builtin {
color: #f4eee4;
text-shadow: 0 0 2px #393a33, 0 0 8px #f39f0575, 0 0 2px #f39f0575;
}
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable {
color: #f87c32;
}
.token.operator,
.token.entity,
.token.url {
color: #67cdcc;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.inserted {
color: green;
}

View File

@@ -0,0 +1,432 @@
"""Flow structure builder for analyzing Flow execution."""
from __future__ import annotations
from collections import defaultdict
import inspect
from typing import TYPE_CHECKING, Any
from crewai.flow.constants import OR_CONDITION
from crewai.flow.types import FlowMethodName
from crewai.flow.utils import (
_extract_all_methods_recursive,
is_flow_condition_dict,
is_simple_flow_condition,
)
from crewai.flow.visualization.schema import extract_method_signature
from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge
if TYPE_CHECKING:
from crewai.flow.flow import Flow
def _extract_direct_or_triggers(
condition: str | dict[str, Any] | list[Any],
) -> list[str]:
"""Extract direct OR-level trigger strings from a condition.
This function extracts strings that would directly trigger a listener,
meaning they appear at the top level of an OR condition. Strings nested
inside AND conditions are NOT considered direct triggers for router paths.
For example:
- or_("a", "b") -> ["a", "b"] (both are direct triggers)
- and_("a", "b") -> [] (neither are direct triggers, both required)
- or_(and_("a", "b"), "c") -> ["c"] (only "c" is a direct trigger)
Args:
condition: Can be a string, dict, or list.
Returns:
List of direct OR-level trigger strings.
"""
if isinstance(condition, str):
return [condition]
if isinstance(condition, dict):
cond_type = condition.get("type", "OR")
conditions_list = condition.get("conditions", [])
if cond_type == "OR":
strings = []
for sub_cond in conditions_list:
strings.extend(_extract_direct_or_triggers(sub_cond))
return strings
else:
return []
if isinstance(condition, list):
strings = []
for item in condition:
strings.extend(_extract_direct_or_triggers(item))
return strings
if callable(condition) and hasattr(condition, "__name__"):
return [condition.__name__]
return []
def _extract_all_trigger_names(
condition: str | dict[str, Any] | list[Any],
) -> list[str]:
"""Extract ALL trigger names from a condition for display purposes.
Unlike _extract_direct_or_triggers, this extracts ALL strings and method
names from the entire condition tree, including those nested in AND conditions.
This is used for displaying trigger information in the UI.
For example:
- or_("a", "b") -> ["a", "b"]
- and_("a", "b") -> ["a", "b"]
- or_(and_("a", method_6), method_4) -> ["a", "method_6", "method_4"]
Args:
condition: Can be a string, dict, or list.
Returns:
List of all trigger names found in the condition.
"""
if isinstance(condition, str):
return [condition]
if isinstance(condition, dict):
conditions_list = condition.get("conditions", [])
strings = []
for sub_cond in conditions_list:
strings.extend(_extract_all_trigger_names(sub_cond))
return strings
if isinstance(condition, list):
strings = []
for item in condition:
strings.extend(_extract_all_trigger_names(item))
return strings
if callable(condition) and hasattr(condition, "__name__"):
return [condition.__name__]
return []
def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
"""Build a structure representation of a Flow's execution.
Args:
flow: Flow instance to analyze.
Returns:
Dictionary with nodes, edges, start_methods, and router_methods.
"""
nodes: dict[str, NodeMetadata] = {}
edges: list[StructureEdge] = []
start_methods: list[str] = []
router_methods: list[str] = []
for method_name, method in flow._methods.items():
node_metadata: NodeMetadata = {"type": "listen"}
if hasattr(method, "__is_start_method__") and method.__is_start_method__:
node_metadata["type"] = "start"
start_methods.append(method_name)
if hasattr(method, "__is_router__") and method.__is_router__:
node_metadata["is_router"] = True
node_metadata["type"] = "router"
router_methods.append(method_name)
node_metadata["condition_type"] = "IF"
if method_name in flow._router_paths:
node_metadata["router_paths"] = [
str(p) for p in flow._router_paths[method_name]
]
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
node_metadata["trigger_methods"] = [
str(m) for m in method.__trigger_methods__
]
if hasattr(method, "__condition_type__") and method.__condition_type__:
if "condition_type" not in node_metadata:
node_metadata["condition_type"] = method.__condition_type__
if (
hasattr(method, "__trigger_condition__")
and method.__trigger_condition__ is not None
):
node_metadata["trigger_condition"] = method.__trigger_condition__
if "trigger_methods" not in node_metadata:
extracted = _extract_all_trigger_names(method.__trigger_condition__)
if extracted:
node_metadata["trigger_methods"] = extracted
node_metadata["method_signature"] = extract_method_signature(
method, method_name
)
try:
source_code = inspect.getsource(method)
node_metadata["source_code"] = source_code
try:
source_lines, start_line = inspect.getsourcelines(method)
node_metadata["source_lines"] = source_lines
node_metadata["source_start_line"] = start_line
except (OSError, TypeError):
pass
try:
source_file = inspect.getsourcefile(method)
if source_file:
node_metadata["source_file"] = source_file
except (OSError, TypeError):
try:
class_file = inspect.getsourcefile(flow.__class__)
if class_file:
node_metadata["source_file"] = class_file
except (OSError, TypeError):
pass
except (OSError, TypeError):
pass
try:
class_obj = flow.__class__
if class_obj:
class_name = class_obj.__name__
bases = class_obj.__bases__
if bases:
base_strs = []
for base in bases:
if hasattr(base, "__name__"):
if hasattr(base, "__origin__"):
base_strs.append(str(base))
else:
base_strs.append(base.__name__)
else:
base_strs.append(str(base))
try:
source_lines = inspect.getsource(class_obj).split("\n")
_, class_start_line = inspect.getsourcelines(class_obj)
for idx, line in enumerate(source_lines):
stripped = line.strip()
if stripped.startswith("class ") and class_name in stripped:
class_signature = stripped.rstrip(":")
node_metadata["class_signature"] = class_signature
node_metadata["class_line_number"] = (
class_start_line + idx
)
break
except (OSError, TypeError):
class_signature = f"class {class_name}({', '.join(base_strs)})"
node_metadata["class_signature"] = class_signature
else:
class_signature = f"class {class_name}"
node_metadata["class_signature"] = class_signature
node_metadata["class_name"] = class_name
except (OSError, TypeError, AttributeError):
pass
nodes[method_name] = node_metadata
for listener_name, condition_data in flow._listeners.items():
condition_type: str | None = None
trigger_methods_list: list[str] = []
if is_simple_flow_condition(condition_data):
cond_type, methods = condition_data
condition_type = cond_type
trigger_methods_list = [str(m) for m in methods]
elif is_flow_condition_dict(condition_data):
condition_type = condition_data.get("type", OR_CONDITION)
methods_recursive = _extract_all_methods_recursive(condition_data, flow)
trigger_methods_list = [str(m) for m in methods_recursive]
edges.extend(
StructureEdge(
source=str(trigger_method),
target=str(listener_name),
condition_type=condition_type,
is_router_path=False,
)
for trigger_method in trigger_methods_list
if trigger_method in nodes
)
for router_method_name in router_methods:
if router_method_name not in flow._router_paths:
continue
router_paths = flow._router_paths[FlowMethodName(router_method_name)]
for path in router_paths:
for listener_name, condition_data in flow._listeners.items():
trigger_strings_from_cond: list[str] = []
if is_simple_flow_condition(condition_data):
_, methods = condition_data
trigger_strings_from_cond = [str(m) for m in methods]
elif is_flow_condition_dict(condition_data):
trigger_strings_from_cond = _extract_direct_or_triggers(
condition_data
)
if str(path) in trigger_strings_from_cond:
edges.append(
StructureEdge(
source=router_method_name,
target=str(listener_name),
condition_type=None,
is_router_path=True,
)
)
for start_method in flow._start_methods:
if start_method not in nodes and start_method in flow._methods:
method = flow._methods[start_method]
nodes[str(start_method)] = NodeMetadata(type="start")
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
nodes[str(start_method)]["trigger_methods"] = [
str(m) for m in method.__trigger_methods__
]
if hasattr(method, "__condition_type__") and method.__condition_type__:
nodes[str(start_method)]["condition_type"] = method.__condition_type__
return FlowStructure(
nodes=nodes,
edges=edges,
start_methods=start_methods,
router_methods=router_methods,
)
def structure_to_dict(structure: FlowStructure) -> dict[str, Any]:
"""Convert FlowStructure to plain dictionary for serialization.
Args:
structure: FlowStructure to convert.
Returns:
Plain dictionary representation.
"""
return {
"nodes": dict(structure["nodes"]),
"edges": list(structure["edges"]),
"start_methods": list(structure["start_methods"]),
"router_methods": list(structure["router_methods"]),
}
def print_structure_summary(structure: FlowStructure) -> str:
"""Generate human-readable summary of Flow structure.
Args:
structure: FlowStructure to summarize.
Returns:
Formatted string summary.
"""
lines: list[str] = []
lines.append("Flow Execution Structure")
lines.append("=" * 50)
lines.append(f"Total nodes: {len(structure['nodes'])}")
lines.append(f"Total edges: {len(structure['edges'])}")
lines.append(f"Start methods: {len(structure['start_methods'])}")
lines.append(f"Router methods: {len(structure['router_methods'])}")
lines.append("")
if structure["start_methods"]:
lines.append("Start Methods:")
for method_name in structure["start_methods"]:
node = structure["nodes"][method_name]
lines.append(f" - {method_name}")
if node.get("condition_type"):
lines.append(f" Condition: {node['condition_type']}")
if node.get("trigger_methods"):
lines.append(f" Triggers on: {', '.join(node['trigger_methods'])}")
lines.append("")
if structure["router_methods"]:
lines.append("Router Methods:")
for method_name in structure["router_methods"]:
node = structure["nodes"][method_name]
lines.append(f" - {method_name}")
if node.get("router_paths"):
lines.append(f" Paths: {', '.join(node['router_paths'])}")
lines.append("")
if structure["edges"]:
lines.append("Connections:")
for edge in structure["edges"]:
edge_type = ""
if edge["is_router_path"]:
edge_type = " [Router Path]"
elif edge["condition_type"]:
edge_type = f" [{edge['condition_type']}]"
lines.append(f" {edge['source']} -> {edge['target']}{edge_type}")
lines.append("")
return "\n".join(lines)
def calculate_execution_paths(structure: FlowStructure) -> int:
"""Calculate number of possible execution paths through the flow.
Args:
structure: FlowStructure to analyze.
Returns:
Number of possible execution paths.
"""
graph = defaultdict(list)
for edge in structure["edges"]:
graph[edge["source"]].append(
{
"target": edge["target"],
"is_router": edge["is_router_path"],
"condition": edge["condition_type"],
}
)
all_nodes = set(structure["nodes"].keys())
nodes_with_outgoing = set(edge["source"] for edge in structure["edges"])
terminal_nodes = all_nodes - nodes_with_outgoing
if not structure["start_methods"] or not terminal_nodes:
return 0
def count_paths_from(node: str, visited: set[str]) -> int:
if node in terminal_nodes:
return 1
if node in visited:
return 0
visited.add(node)
outgoing = graph[node]
if not outgoing:
visited.remove(node)
return 1
if node in structure["router_methods"]:
total = 0
for edge_info in outgoing:
target = str(edge_info["target"])
total += count_paths_from(target, visited.copy())
visited.remove(node)
return total
total = 0
for edge_info in outgoing:
target = str(edge_info["target"])
total += count_paths_from(target, visited.copy())
visited.remove(node)
return total if total > 0 else 1
total_paths = 0
for start in structure["start_methods"]:
total_paths += count_paths_from(start, set())
return max(total_paths, 1)

View File

@@ -0,0 +1,8 @@
"""Flow structure visualization renderers."""
from crewai.flow.visualization.renderers.interactive import render_interactive
__all__ = [
"render_interactive",
]

View File

@@ -0,0 +1,343 @@
"""Interactive HTML renderer for Flow structure visualization."""
import json
from pathlib import Path
import tempfile
from typing import Any, ClassVar
import webbrowser
from jinja2 import Environment, FileSystemLoader, nodes, select_autoescape
from jinja2.ext import Extension
from jinja2.parser import Parser
from crewai.flow.visualization.builder import calculate_execution_paths
from crewai.flow.visualization.types import FlowStructure
class CSSExtension(Extension):
"""Jinja2 extension for rendering CSS link tags.
Provides {% css 'path/to/file.css' %} tag syntax.
"""
tags: ClassVar[set[str]] = {"css"} # type: ignore[assignment]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse {% css 'styles.css' %} tag.
Args:
parser: Jinja2 parser instance.
Returns:
Output node with rendered CSS link tag.
"""
lineno: int = next(parser.stream).lineno
args: list[nodes.Expr] = [parser.parse_expression()]
return nodes.Output([self.call_method("_render_css", args)]).set_lineno(lineno)
def _render_css(self, href: str) -> str:
"""Render CSS link tag.
Args:
href: Path to CSS file.
Returns:
HTML link tag string.
"""
return f'<link rel="stylesheet" href="{href}">'
class JSExtension(Extension):
"""Jinja2 extension for rendering script tags.
Provides {% js 'path/to/file.js' %} tag syntax.
"""
tags: ClassVar[set[str]] = {"js"} # type: ignore[assignment]
def parse(self, parser: Parser) -> nodes.Node:
"""Parse {% js 'script.js' %} tag.
Args:
parser: Jinja2 parser instance.
Returns:
Output node with rendered script tag.
"""
lineno: int = next(parser.stream).lineno
args: list[nodes.Expr] = [parser.parse_expression()]
return nodes.Output([self.call_method("_render_js", args)]).set_lineno(lineno)
def _render_js(self, src: str) -> str:
"""Render script tag.
Args:
src: Path to JavaScript file.
Returns:
HTML script tag string.
"""
return f'<script src="{src}"></script>'
CREWAI_ORANGE = "#FF5A50"
DARK_GRAY = "#333333"
WHITE = "#FFFFFF"
GRAY = "#666666"
BG_DARK = "#0d1117"
BG_CARD = "#161b22"
BORDER_SUBTLE = "#30363d"
TEXT_PRIMARY = "#e6edf3"
TEXT_SECONDARY = "#7d8590"
def render_interactive(
dag: FlowStructure,
filename: str = "flow_dag.html",
show: bool = True,
) -> str:
"""Create interactive HTML visualization of Flow structure.
Generates three output files in a temporary directory: HTML template,
CSS stylesheet, and JavaScript. Optionally opens the visualization in
default browser.
Args:
dag: FlowStructure to visualize.
filename: Output HTML filename (basename only, no path).
show: Whether to open in browser.
Returns:
Absolute path to generated HTML file in temporary directory.
"""
nodes_list: list[dict[str, Any]] = []
for name, metadata in dag["nodes"].items():
node_type: str = metadata.get("type", "listen")
color_config: dict[str, Any]
font_color: str
border_width: int
if node_type == "start":
color_config = {
"background": CREWAI_ORANGE,
"border": CREWAI_ORANGE,
"highlight": {
"background": CREWAI_ORANGE,
"border": CREWAI_ORANGE,
},
}
font_color = WHITE
border_width = 2
elif node_type == "router":
color_config = {
"background": DARK_GRAY,
"border": CREWAI_ORANGE,
"highlight": {
"background": DARK_GRAY,
"border": CREWAI_ORANGE,
},
}
font_color = WHITE
border_width = 3
else:
color_config = {
"background": DARK_GRAY,
"border": DARK_GRAY,
"highlight": {
"background": DARK_GRAY,
"border": DARK_GRAY,
},
}
font_color = WHITE
border_width = 2
title_parts: list[str] = []
type_badge_bg: str = (
CREWAI_ORANGE if node_type in ["start", "router"] else DARK_GRAY
)
title_parts.append(f"""
<div style="border-bottom: 1px solid rgba(102,102,102,0.15); padding-bottom: 8px; margin-bottom: 10px;">
<div style="font-size: 13px; font-weight: 700; color: {DARK_GRAY}; margin-bottom: 6px;">{name}</div>
<span style="display: inline-block; background: {type_badge_bg}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">{node_type}</span>
</div>
""")
if metadata.get("condition_type"):
condition = metadata["condition_type"]
if condition == "AND":
condition_badge_bg = "rgba(255,90,80,0.12)"
condition_color = CREWAI_ORANGE
elif condition == "IF":
condition_badge_bg = "rgba(255,90,80,0.18)"
condition_color = CREWAI_ORANGE
else:
condition_badge_bg = "rgba(102,102,102,0.12)"
condition_color = GRAY
title_parts.append(f"""
<div style="margin-bottom: 8px;">
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 3px; font-weight: 600;">Condition</div>
<span style="display: inline-block; background: {condition_badge_bg}; color: {condition_color}; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 700;">{condition}</span>
</div>
""")
if metadata.get("trigger_methods"):
triggers = metadata["trigger_methods"]
triggers_items = "".join(
[
f'<li style="margin: 3px 0;"><code style="background: rgba(102,102,102,0.08); padding: 2px 6px; border-radius: 3px; font-size: 10px; color: {DARK_GRAY}; border: 1px solid rgba(102,102,102,0.12);">{t}</code></li>'
for t in triggers
]
)
title_parts.append(f"""
<div style="margin-bottom: 8px;">
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">Triggers</div>
<ul style="list-style: none; padding: 0; margin: 0;">{triggers_items}</ul>
</div>
""")
if metadata.get("router_paths"):
paths = metadata["router_paths"]
paths_items = "".join(
[
f'<li style="margin: 3px 0;"><code style="background: rgba(255,90,80,0.08); padding: 2px 6px; border-radius: 3px; font-size: 10px; color: {CREWAI_ORANGE}; border: 1px solid rgba(255,90,80,0.2); font-weight: 600;">{p}</code></li>'
for p in paths
]
)
title_parts.append(f"""
<div>
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">Router Paths</div>
<ul style="list-style: none; padding: 0; margin: 0;">{paths_items}</ul>
</div>
""")
bg_color = color_config["background"]
border_color = color_config["border"]
nodes_list.append(
{
"id": name,
"label": name,
"title": "".join(title_parts),
"shape": "custom",
"size": 30,
"nodeStyle": {
"name": name,
"bgColor": bg_color,
"borderColor": border_color,
"borderWidth": border_width,
"fontColor": font_color,
},
"opacity": 1.0,
"glowSize": 0,
"glowColor": None,
}
)
execution_paths: int = calculate_execution_paths(dag)
edges_list: list[dict[str, Any]] = []
for edge in dag["edges"]:
edge_label: str = ""
edge_color: str = GRAY
edge_dashes: bool | list[int] = False
if edge["is_router_path"]:
edge_color = CREWAI_ORANGE
edge_dashes = [15, 10]
elif edge["condition_type"] == "AND":
edge_label = "AND"
edge_color = CREWAI_ORANGE
elif edge["condition_type"] == "OR":
edge_label = "OR"
edge_color = GRAY
edge_data: dict[str, Any] = {
"from": edge["source"],
"to": edge["target"],
"label": edge_label,
"arrows": "to",
"width": 2,
"selectionWidth": 0,
"color": {
"color": edge_color,
"highlight": edge_color,
},
}
if edge_dashes is not False:
edge_data["dashes"] = edge_dashes
edges_list.append(edge_data)
template_dir = Path(__file__).parent.parent / "assets"
env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html", "xml", "css", "js"]),
variable_start_string="'{{",
variable_end_string="}}'",
extensions=[CSSExtension, JSExtension],
)
temp_dir = Path(tempfile.mkdtemp(prefix="crewai_flow_"))
output_path = temp_dir / Path(filename).name
css_filename = output_path.stem + "_style.css"
css_output_path = temp_dir / css_filename
js_filename = output_path.stem + "_script.js"
js_output_path = temp_dir / js_filename
css_file = template_dir / "style.css"
css_content = css_file.read_text(encoding="utf-8")
css_content = css_content.replace("'{{ WHITE }}'", WHITE)
css_content = css_content.replace("'{{ DARK_GRAY }}'", DARK_GRAY)
css_content = css_content.replace("'{{ GRAY }}'", GRAY)
css_content = css_content.replace("'{{ CREWAI_ORANGE }}'", CREWAI_ORANGE)
css_output_path.write_text(css_content, encoding="utf-8")
js_file = template_dir / "interactive.js"
js_content = js_file.read_text(encoding="utf-8")
dag_nodes_json = json.dumps(dag["nodes"])
dag_full_json = json.dumps(dag)
js_content = js_content.replace("{{ WHITE }}", WHITE)
js_content = js_content.replace("{{ DARK_GRAY }}", DARK_GRAY)
js_content = js_content.replace("{{ GRAY }}", GRAY)
js_content = js_content.replace("{{ CREWAI_ORANGE }}", CREWAI_ORANGE)
js_content = js_content.replace("'{{ nodeData }}'", dag_nodes_json)
js_content = js_content.replace("'{{ dagData }}'", dag_full_json)
js_content = js_content.replace("'{{ nodes_list_json }}'", json.dumps(nodes_list))
js_content = js_content.replace("'{{ edges_list_json }}'", json.dumps(edges_list))
js_output_path.write_text(js_content, encoding="utf-8")
template = env.get_template("interactive_flow.html.j2")
html_content = template.render(
CREWAI_ORANGE=CREWAI_ORANGE,
DARK_GRAY=DARK_GRAY,
WHITE=WHITE,
GRAY=GRAY,
BG_DARK=BG_DARK,
BG_CARD=BG_CARD,
BORDER_SUBTLE=BORDER_SUBTLE,
TEXT_PRIMARY=TEXT_PRIMARY,
TEXT_SECONDARY=TEXT_SECONDARY,
nodes_list_json=json.dumps(nodes_list),
edges_list_json=json.dumps(edges_list),
dag_nodes_count=len(dag["nodes"]),
dag_edges_count=len(dag["edges"]),
execution_paths=execution_paths,
css_path=css_filename,
js_path=js_filename,
)
output_path.write_text(html_content, encoding="utf-8")
if show:
webbrowser.open(f"file://{output_path.absolute()}")
return str(output_path.absolute())

View File

@@ -0,0 +1,104 @@
"""OpenAPI schema conversion utilities for Flow methods."""
import inspect
from typing import Any, get_args, get_origin
def type_to_openapi_schema(type_hint: Any) -> dict[str, Any]:
"""Convert Python type hint to OpenAPI schema.
Args:
type_hint: Python type hint to convert.
Returns:
OpenAPI schema dictionary.
"""
if type_hint is inspect.Parameter.empty:
return {}
if type_hint is None or type_hint is type(None):
return {"type": "null"}
if hasattr(type_hint, "__module__") and hasattr(type_hint, "__name__"):
if type_hint.__module__ == "typing" and type_hint.__name__ == "Any":
return {}
type_str = str(type_hint)
if type_str == "typing.Any" or type_str == "<class 'typing.Any'>":
return {}
if isinstance(type_hint, str):
return {"type": type_hint}
origin = get_origin(type_hint)
args = get_args(type_hint)
if type_hint is str:
return {"type": "string"}
if type_hint is int:
return {"type": "integer"}
if type_hint is float:
return {"type": "number"}
if type_hint is bool:
return {"type": "boolean"}
if type_hint is dict or origin is dict:
if args and len(args) > 1:
return {
"type": "object",
"additionalProperties": type_to_openapi_schema(args[1]),
}
return {"type": "object"}
if type_hint is list or origin is list:
if args:
return {"type": "array", "items": type_to_openapi_schema(args[0])}
return {"type": "array"}
if hasattr(type_hint, "__name__"):
return {"type": "object", "className": type_hint.__name__}
return {}
def extract_method_signature(method: Any, method_name: str) -> dict[str, Any]:
"""Extract method signature as OpenAPI schema with documentation.
Args:
method: Method to analyze.
method_name: Method name.
Returns:
Dictionary with operationId, parameters, returns, summary, and description.
"""
try:
sig = inspect.signature(method)
parameters = {}
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
parameters[param_name] = type_to_openapi_schema(param.annotation)
return_type = type_to_openapi_schema(sig.return_annotation)
docstring = inspect.getdoc(method)
result: dict[str, Any] = {
"operationId": method_name,
"parameters": parameters,
"returns": return_type,
}
if docstring:
lines = docstring.strip().split("\n")
summary = lines[0].strip()
if summary:
result["summary"] = summary
if len(lines) > 1:
description = "\n".join(line.strip() for line in lines[1:]).strip()
if description:
result["description"] = description
return result
except Exception:
return {"operationId": method_name, "parameters": {}, "returns": {}}

View File

@@ -0,0 +1,40 @@
"""Type definitions for Flow structure visualization."""
from typing import Any, TypedDict
class NodeMetadata(TypedDict, total=False):
"""Metadata for a single node in the flow structure."""
type: str
is_router: bool
router_paths: list[str]
condition_type: str | None
trigger_methods: list[str]
trigger_condition: dict[str, Any] | None
method_signature: dict[str, Any]
source_code: str
source_lines: list[str]
source_start_line: int
source_file: str
class_signature: str
class_name: str
class_line_number: int
class StructureEdge(TypedDict):
"""Represents a connection in the flow structure."""
source: str
target: str
condition_type: str | None
is_router_path: bool
class FlowStructure(TypedDict):
"""Complete structure representation of a Flow."""
nodes: dict[str, NodeMetadata]
edges: list[StructureEdge]
start_methods: list[str]
router_methods: list[str]

View File

@@ -1,342 +0,0 @@
"""
Utilities for creating visual representations of flow structures.
This module provides functions for generating network visualizations of flows,
including node placement, edge creation, and visual styling. It handles the
conversion of flow structures into visual network graphs with appropriate
styling and layout.
Example
-------
>>> flow = Flow()
>>> net = Network(directed=True)
>>> node_positions = compute_positions(flow, node_levels)
>>> add_nodes_to_network(net, flow, node_positions, node_styles)
>>> add_edges(net, flow, node_positions, colors)
"""
import ast
import inspect
from typing import Any
from crewai.flow.config import (
CrewNodeStyle,
FlowColors,
MethodNodeStyle,
NodeStyles,
RouterNodeStyle,
StartNodeStyle,
)
from crewai.flow.utils import (
build_ancestor_dict,
build_parent_children_dict,
get_child_index,
is_ancestor,
)
from crewai.utilities.printer import Printer
_printer = Printer()
def method_calls_crew(method: Any) -> bool:
"""
Check if the method contains a call to `.crew()`, `.kickoff()`, or `.kickoff_async()`.
Parameters
----------
method : Any
The method to analyze for crew or agent execution calls.
Returns
-------
bool
True if the method calls .crew(), .kickoff(), or .kickoff_async(), False otherwise.
Notes
-----
Uses AST analysis to detect method calls, specifically looking for
attribute access of 'crew', 'kickoff', or 'kickoff_async'.
This includes both traditional Crew execution (.crew()) and Agent/LiteAgent
execution (.kickoff() or .kickoff_async()).
"""
try:
source = inspect.getsource(method)
source = inspect.cleandoc(source)
tree = ast.parse(source)
except Exception as e:
_printer.print(f"Could not parse method {method.__name__}: {e}", color="red")
return False
class CrewCallVisitor(ast.NodeVisitor):
"""AST visitor to detect .crew(), .kickoff(), or .kickoff_async() method calls."""
def __init__(self) -> None:
self.found = False
def visit_Call(self, node: ast.Call) -> None:
if isinstance(node.func, ast.Attribute):
if node.func.attr in ("crew", "kickoff", "kickoff_async"):
self.found = True
self.generic_visit(node)
visitor = CrewCallVisitor()
visitor.visit(tree)
return visitor.found
def add_nodes_to_network(
net: Any,
flow: Any,
node_positions: dict[str, tuple[float, float]],
node_styles: NodeStyles,
) -> None:
"""
Add nodes to the network visualization with appropriate styling.
Parameters
----------
net : Any
The pyvis Network instance to add nodes to.
flow : Any
The flow instance containing method information.
node_positions : Dict[str, Tuple[float, float]]
Dictionary mapping node names to their (x, y) positions.
node_styles : Dict[str, Dict[str, Any]]
Dictionary containing style configurations for different node types.
Notes
-----
Node types include:
- Start methods
- Router methods
- Crew methods
- Regular methods
"""
def human_friendly_label(method_name: str) -> str:
return method_name.replace("_", " ").title()
node_style: (
StartNodeStyle | RouterNodeStyle | CrewNodeStyle | MethodNodeStyle | None
)
for method_name, (x, y) in node_positions.items():
method = flow._methods.get(method_name)
if hasattr(method, "__is_start_method__"):
node_style = node_styles["start"]
elif hasattr(method, "__is_router__"):
node_style = node_styles["router"]
elif method_calls_crew(method):
node_style = node_styles["crew"]
else:
node_style = node_styles["method"]
node_style = node_style.copy()
label = human_friendly_label(method_name)
node_style.update(
{
"label": label,
"shape": "box",
"font": {
"multi": "html",
"color": node_style.get("font", {}).get("color", "#FFFFFF"),
},
}
)
net.add_node(
method_name,
x=x,
y=y,
fixed=True,
physics=False,
**node_style,
)
def compute_positions(
flow: Any,
node_levels: dict[str, int],
y_spacing: float = 150,
x_spacing: float = 300,
) -> dict[str, tuple[float, float]]:
"""
Compute the (x, y) positions for each node in the flow graph.
Parameters
----------
flow : Any
The flow instance to compute positions for.
node_levels : Dict[str, int]
Dictionary mapping node names to their hierarchical levels.
y_spacing : float, optional
Vertical spacing between levels, by default 150.
x_spacing : float, optional
Horizontal spacing between nodes, by default 300.
Returns
-------
Dict[str, Tuple[float, float]]
Dictionary mapping node names to their (x, y) coordinates.
"""
level_nodes: dict[int, list[str]] = {}
node_positions: dict[str, tuple[float, float]] = {}
for method_name, level in node_levels.items():
level_nodes.setdefault(level, []).append(method_name)
for level, nodes in level_nodes.items():
x_offset = -(len(nodes) - 1) * x_spacing / 2 # Center nodes horizontally
for i, method_name in enumerate(nodes):
x = x_offset + i * x_spacing
y = level * y_spacing
node_positions[method_name] = (x, y)
return node_positions
def add_edges(
net: Any,
flow: Any,
node_positions: dict[str, tuple[float, float]],
colors: FlowColors,
) -> None:
edge_smooth: dict[str, str | float] = {"type": "continuous"} # Default value
"""
Add edges to the network visualization with appropriate styling.
Parameters
----------
net : Any
The pyvis Network instance to add edges to.
flow : Any
The flow instance containing edge information.
node_positions : Dict[str, Tuple[float, float]]
Dictionary mapping node names to their positions.
colors : Dict[str, str]
Dictionary mapping edge types to their colors.
Notes
-----
- Handles both normal listener edges and router edges
- Applies appropriate styling (color, dashes) based on edge type
- Adds curvature to edges when needed (cycles or multiple children)
"""
ancestors = build_ancestor_dict(flow)
parent_children = build_parent_children_dict(flow)
# Edges for normal listeners
for method_name in flow._listeners:
condition_type, trigger_methods = flow._listeners[method_name]
is_and_condition = condition_type == "AND"
for trigger in trigger_methods:
# Check if nodes exist before adding edges
if trigger in node_positions and method_name in node_positions:
is_router_edge = any(
trigger in paths for paths in flow._router_paths.values()
)
edge_color = colors["router_edge"] if is_router_edge else colors["edge"]
is_cycle_edge = is_ancestor(trigger, method_name, ancestors)
parent_has_multiple_children = len(parent_children.get(trigger, [])) > 1
needs_curvature = is_cycle_edge or parent_has_multiple_children
if needs_curvature:
source_pos = node_positions.get(trigger)
target_pos = node_positions.get(method_name)
if source_pos and target_pos:
dx = target_pos[0] - source_pos[0]
smooth_type = "curvedCCW" if dx <= 0 else "curvedCW"
index = get_child_index(trigger, method_name, parent_children)
edge_smooth = {
"type": smooth_type,
"roundness": 0.2 + (0.1 * index),
}
else:
edge_smooth = {"type": "cubicBezier"}
else:
edge_smooth.update({"type": "continuous"})
edge_style = {
"color": edge_color,
"width": 2,
"arrows": "to",
"dashes": True if is_router_edge or is_and_condition else False,
"smooth": edge_smooth,
}
net.add_edge(trigger, method_name, **edge_style)
else:
# Nodes not found in node_positions. Check if it's a known router outcome and a known method.
is_router_edge = any(
trigger in paths for paths in flow._router_paths.values()
)
# Check if method_name is a known method
method_known = method_name in flow._methods
# If it's a known router edge and the method is known, don't warn.
# This means the path is legitimate, just not reflected as nodes here.
if not (is_router_edge and method_known):
_printer.print(
f"Warning: No node found for '{trigger}' or '{method_name}'. Skipping edge.",
color="yellow",
)
# Edges for router return paths
for router_method_name, paths in flow._router_paths.items():
for path in paths:
for listener_name, (
_condition_type,
trigger_methods,
) in flow._listeners.items():
if path in trigger_methods:
if (
router_method_name in node_positions
and listener_name in node_positions
):
is_cycle_edge = is_ancestor(
router_method_name, listener_name, ancestors
)
parent_has_multiple_children = (
len(parent_children.get(router_method_name, [])) > 1
)
needs_curvature = is_cycle_edge or parent_has_multiple_children
if needs_curvature:
source_pos = node_positions.get(router_method_name)
target_pos = node_positions.get(listener_name)
if source_pos and target_pos:
dx = target_pos[0] - source_pos[0]
smooth_type = "curvedCCW" if dx <= 0 else "curvedCW"
index = get_child_index(
router_method_name, listener_name, parent_children
)
edge_smooth = {
"type": smooth_type,
"roundness": 0.2 + (0.1 * index),
}
else:
edge_smooth = {"type": "cubicBezier"}
else:
edge_smooth.update({"type": "continuous"})
edge_style = {
"color": colors["router_edge"],
"width": 2,
"arrows": "to",
"dashes": True,
"smooth": edge_smooth,
}
net.add_edge(router_method_name, listener_name, **edge_style)
else:
# Same check here: known router edge and known method?
method_known = listener_name in flow._methods
if not method_known:
_printer.print(
f"Warning: No node found for '{router_method_name}' or '{listener_name}'. Skipping edge.",
color="yellow",
)

View File

@@ -850,31 +850,6 @@ def test_flow_plotting():
assert isinstance(received_events[0].timestamp, datetime)
def test_method_calls_crew_detection():
"""Test that method_calls_crew() detects .crew(), .kickoff(), and .kickoff_async() calls."""
from crewai.flow.visualization_utils import method_calls_crew
from crewai import Agent
# Test with a real Flow that uses agent.kickoff()
class FlowWithAgentKickoff(Flow):
@start()
def run_agent(self):
agent = Agent(role="test", goal="test", backstory="test")
return agent.kickoff("query")
flow = FlowWithAgentKickoff()
assert method_calls_crew(flow.run_agent) is True
# Test with a Flow that has no crew/agent calls
class FlowWithoutCrewCalls(Flow):
@start()
def simple_method(self):
return "Just a regular method"
flow2 = FlowWithoutCrewCalls()
assert method_calls_crew(flow2.simple_method) is False
def test_multiple_routers_from_same_trigger():
"""Test that multiple routers triggered by the same method all activate their listeners."""
execution_order = []
@@ -1058,3 +1033,354 @@ def test_nested_and_or_conditions():
# method_8 should execute after method_7
assert execution_order.index("method_8") > execution_order.index("method_7")
def test_diamond_dependency_pattern():
"""Test diamond pattern where two parallel paths converge at a final step."""
execution_order = []
class DiamondFlow(Flow):
@start()
def start(self):
execution_order.append("start")
return "started"
@listen(start)
def path_a(self):
execution_order.append("path_a")
return "a_done"
@listen(start)
def path_b(self):
execution_order.append("path_b")
return "b_done"
@listen(and_(path_a, path_b))
def converge(self):
execution_order.append("converge")
return "converged"
flow = DiamondFlow()
flow.kickoff()
# Start should execute first
assert execution_order[0] == "start"
# Both paths should execute after start
assert "path_a" in execution_order
assert "path_b" in execution_order
assert execution_order.index("path_a") > execution_order.index("start")
assert execution_order.index("path_b") > execution_order.index("start")
# Converge should be last and after both paths
assert execution_order[-1] == "converge"
assert execution_order.index("converge") > execution_order.index("path_a")
assert execution_order.index("converge") > execution_order.index("path_b")
def test_router_cascade_chain():
"""Test a chain of routers where each router triggers the next."""
execution_order = []
class RouterCascadeFlow(Flow):
def __init__(self):
super().__init__()
self.state["level"] = 1
@start()
def begin(self):
execution_order.append("begin")
return "started"
@router(begin)
def router_level_1(self):
execution_order.append("router_level_1")
return "level_1_path"
@listen("level_1_path")
def process_level_1(self):
execution_order.append("process_level_1")
self.state["level"] = 2
return "level_1_done"
@router(process_level_1)
def router_level_2(self):
execution_order.append("router_level_2")
return "level_2_path"
@listen("level_2_path")
def process_level_2(self):
execution_order.append("process_level_2")
self.state["level"] = 3
return "level_2_done"
@router(process_level_2)
def router_level_3(self):
execution_order.append("router_level_3")
return "final_path"
@listen("final_path")
def finalize(self):
execution_order.append("finalize")
return "complete"
flow = RouterCascadeFlow()
flow.kickoff()
expected_order = [
"begin",
"router_level_1",
"process_level_1",
"router_level_2",
"process_level_2",
"router_level_3",
"finalize",
]
assert execution_order == expected_order
assert flow.state["level"] == 3
def test_complex_and_or_branching():
"""Test complex branching with multiple AND and OR conditions."""
execution_order = []
class ComplexBranchingFlow(Flow):
@start()
def init(self):
execution_order.append("init")
@listen(init)
def branch_1a(self):
execution_order.append("branch_1a")
@listen(init)
def branch_1b(self):
execution_order.append("branch_1b")
@listen(init)
def branch_1c(self):
execution_order.append("branch_1c")
# Requires 1a AND 1b (ignoring 1c)
@listen(and_(branch_1a, branch_1b))
def branch_2a(self):
execution_order.append("branch_2a")
# Requires any of 1a, 1b, or 1c
@listen(or_(branch_1a, branch_1b, branch_1c))
def branch_2b(self):
execution_order.append("branch_2b")
# Final step requires 2a AND 2b
@listen(and_(branch_2a, branch_2b))
def final(self):
execution_order.append("final")
flow = ComplexBranchingFlow()
flow.kickoff()
# Verify all branches executed
assert "init" in execution_order
assert "branch_1a" in execution_order
assert "branch_1b" in execution_order
assert "branch_1c" in execution_order
assert "branch_2a" in execution_order
assert "branch_2b" in execution_order
assert "final" in execution_order
# Verify order constraints
assert execution_order.index("branch_2a") > execution_order.index("branch_1a")
assert execution_order.index("branch_2a") > execution_order.index("branch_1b")
# branch_2b should trigger after at least one of 1a, 1b, or 1c
min_branch_1_index = min(
execution_order.index("branch_1a"),
execution_order.index("branch_1b"),
execution_order.index("branch_1c"),
)
assert execution_order.index("branch_2b") > min_branch_1_index
# Final should be last and after both 2a and 2b
assert execution_order[-1] == "final"
assert execution_order.index("final") > execution_order.index("branch_2a")
assert execution_order.index("final") > execution_order.index("branch_2b")
def test_conditional_router_paths_exclusivity():
"""Test that only the returned router path activates, not all paths."""
execution_order = []
class ConditionalRouterFlow(Flow):
def __init__(self):
super().__init__()
self.state["condition"] = "take_path_b"
@start()
def begin(self):
execution_order.append("begin")
@router(begin)
def decision_point(self):
execution_order.append("decision_point")
if self.state["condition"] == "take_path_a":
return "path_a"
elif self.state["condition"] == "take_path_b":
return "path_b"
else:
return "path_c"
@listen("path_a")
def handle_path_a(self):
execution_order.append("handle_path_a")
@listen("path_b")
def handle_path_b(self):
execution_order.append("handle_path_b")
@listen("path_c")
def handle_path_c(self):
execution_order.append("handle_path_c")
flow = ConditionalRouterFlow()
flow.kickoff()
# Should only execute path_b, not path_a or path_c
assert "begin" in execution_order
assert "decision_point" in execution_order
assert "handle_path_b" in execution_order
assert "handle_path_a" not in execution_order
assert "handle_path_c" not in execution_order
def test_state_consistency_across_parallel_branches():
"""Test that state remains consistent when branches execute sequentially.
Note: Branches triggered by the same parent execute sequentially, not in parallel.
This ensures predictable state mutations and prevents race conditions.
"""
execution_order = []
class StateConsistencyFlow(Flow):
def __init__(self):
super().__init__()
self.state["counter"] = 0
self.state["branch_a_value"] = None
self.state["branch_b_value"] = None
@start()
def init(self):
execution_order.append("init")
self.state["counter"] = 10
@listen(init)
def branch_a(self):
execution_order.append("branch_a")
# Read counter value
self.state["branch_a_value"] = self.state["counter"]
self.state["counter"] += 1
@listen(init)
def branch_b(self):
execution_order.append("branch_b")
# Read counter value
self.state["branch_b_value"] = self.state["counter"]
self.state["counter"] += 5
@listen(and_(branch_a, branch_b))
def verify_state(self):
execution_order.append("verify_state")
flow = StateConsistencyFlow()
flow.kickoff()
# Branches execute sequentially, so branch_a runs first, then branch_b
assert flow.state["branch_a_value"] == 10 # Sees initial value
assert flow.state["branch_b_value"] == 11 # Sees value after branch_a increment
# Final counter should reflect both increments sequentially
assert flow.state["counter"] == 16 # 10 + 1 + 5
def test_deeply_nested_conditions():
"""Test deeply nested AND/OR conditions to ensure proper evaluation."""
execution_order = []
class DeeplyNestedFlow(Flow):
@start()
def a(self):
execution_order.append("a")
@start()
def b(self):
execution_order.append("b")
@start()
def c(self):
execution_order.append("c")
@start()
def d(self):
execution_order.append("d")
# Nested: (a AND b) OR (c AND d)
@listen(or_(and_(a, b), and_(c, d)))
def result(self):
execution_order.append("result")
flow = DeeplyNestedFlow()
flow.kickoff()
# All start methods should execute
assert "a" in execution_order
assert "b" in execution_order
assert "c" in execution_order
assert "d" in execution_order
# Result should execute after all starts
assert "result" in execution_order
assert execution_order.index("result") > execution_order.index("a")
assert execution_order.index("result") > execution_order.index("b")
assert execution_order.index("result") > execution_order.index("c")
assert execution_order.index("result") > execution_order.index("d")
def test_mixed_sync_async_execution_order():
"""Test that execution order is preserved with mixed sync/async methods."""
execution_order = []
class MixedSyncAsyncFlow(Flow):
@start()
def sync_start(self):
execution_order.append("sync_start")
@listen(sync_start)
async def async_step_1(self):
execution_order.append("async_step_1")
await asyncio.sleep(0.01)
@listen(async_step_1)
def sync_step_2(self):
execution_order.append("sync_step_2")
@listen(sync_step_2)
async def async_step_3(self):
execution_order.append("async_step_3")
await asyncio.sleep(0.01)
@listen(async_step_3)
def sync_final(self):
execution_order.append("sync_final")
flow = MixedSyncAsyncFlow()
asyncio.run(flow.kickoff_async())
expected_order = [
"sync_start",
"async_step_1",
"sync_step_2",
"async_step_3",
"sync_final",
]
assert execution_order == expected_order

View File

@@ -0,0 +1,497 @@
"""Tests for flow visualization and structure building."""
import json
import os
import tempfile
from pathlib import Path
import pytest
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.visualization import (
build_flow_structure,
print_structure_summary,
structure_to_dict,
visualize_flow_structure,
)
class SimpleFlow(Flow):
"""Simple flow for testing basic visualization."""
@start()
def begin(self):
return "started"
@listen(begin)
def process(self):
return "processed"
class RouterFlow(Flow):
"""Flow with router for testing router visualization."""
@start()
def init(self):
return "initialized"
@router(init)
def decide(self):
if hasattr(self, "state") and self.state.get("path") == "b":
return "path_b"
return "path_a"
@listen("path_a")
def handle_a(self):
return "handled_a"
@listen("path_b")
def handle_b(self):
return "handled_b"
class ComplexFlow(Flow):
"""Complex flow with AND/OR conditions for testing."""
@start()
def start_a(self):
return "a"
@start()
def start_b(self):
return "b"
@listen(and_(start_a, start_b))
def converge_and(self):
return "and_done"
@listen(or_(start_a, start_b))
def converge_or(self):
return "or_done"
@router(converge_and)
def router_decision(self):
return "final_path"
@listen("final_path")
def finalize(self):
return "complete"
def test_build_flow_structure_simple():
"""Test building structure for a simple sequential flow."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
assert structure is not None
assert len(structure["nodes"]) == 2
assert len(structure["edges"]) == 1
node_names = set(structure["nodes"].keys())
assert "begin" in node_names
assert "process" in node_names
assert len(structure["start_methods"]) == 1
assert "begin" in structure["start_methods"]
edge = structure["edges"][0]
assert edge["source"] == "begin"
assert edge["target"] == "process"
assert edge["condition_type"] == "OR"
def test_build_flow_structure_with_router():
"""Test building structure for a flow with router."""
flow = RouterFlow()
structure = build_flow_structure(flow)
assert structure is not None
assert len(structure["nodes"]) == 4
assert len(structure["router_methods"]) == 1
assert "decide" in structure["router_methods"]
router_node = structure["nodes"]["decide"]
assert router_node["type"] == "router"
if "router_paths" in router_node:
assert len(router_node["router_paths"]) >= 1
assert any("path" in path for path in router_node["router_paths"])
router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]]
assert len(router_edges) >= 1
def test_build_flow_structure_with_and_or_conditions():
"""Test building structure for a flow with AND/OR conditions."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
assert structure is not None
and_edges = [
edge
for edge in structure["edges"]
if edge["target"] == "converge_and" and edge["condition_type"] == "AND"
]
assert len(and_edges) == 2
or_edges = [
edge
for edge in structure["edges"]
if edge["target"] == "converge_or" and edge["condition_type"] == "OR"
]
assert len(or_edges) == 2
def test_structure_to_dict():
"""Test converting flow structure to dictionary format."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
assert "nodes" in dag_dict
assert "edges" in dag_dict
assert "start_methods" in dag_dict
assert "router_methods" in dag_dict
assert "begin" in dag_dict["nodes"]
assert "process" in dag_dict["nodes"]
begin_node = dag_dict["nodes"]["begin"]
assert begin_node["type"] == "start"
assert "method_signature" in begin_node
assert "source_code" in begin_node
assert len(dag_dict["edges"]) == 1
edge = dag_dict["edges"][0]
assert "source" in edge
assert "target" in edge
assert "condition_type" in edge
assert "is_router_path" in edge
def test_structure_to_dict_with_router():
"""Test dictionary conversion for flow with router."""
flow = RouterFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
decide_node = dag_dict["nodes"]["decide"]
assert decide_node["type"] == "router"
assert decide_node["is_router"] is True
if "router_paths" in decide_node:
assert len(decide_node["router_paths"]) >= 1
router_edges = [edge for edge in dag_dict["edges"] if edge["is_router_path"]]
assert len(router_edges) >= 1
def test_structure_to_dict_with_complex_conditions():
"""Test dictionary conversion for flow with complex conditions."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
converge_and_node = dag_dict["nodes"]["converge_and"]
assert converge_and_node["condition_type"] == "AND"
assert "trigger_condition" in converge_and_node
assert converge_and_node["trigger_condition"]["type"] == "AND"
converge_or_node = dag_dict["nodes"]["converge_or"]
assert converge_or_node["condition_type"] == "OR"
def test_visualize_flow_structure_creates_html():
"""Test that visualization generates valid HTML file."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
assert os.path.exists(html_file)
with open(html_file, "r", encoding="utf-8") as f:
html_content = f.read()
assert "<!DOCTYPE html>" in html_content
assert "<html" in html_content
assert "CrewAI Flow Visualization" in html_content
assert "network-container" in html_content
assert "drawer" in html_content
assert "nav-controls" in html_content
def test_visualize_flow_structure_creates_assets():
"""Test that visualization creates CSS and JS files."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
html_path = Path(html_file)
css_file = html_path.parent / f"{html_path.stem}_style.css"
js_file = html_path.parent / f"{html_path.stem}_script.js"
assert css_file.exists()
assert js_file.exists()
css_content = css_file.read_text(encoding="utf-8")
assert len(css_content) > 0
assert "body" in css_content or ":root" in css_content
js_content = js_file.read_text(encoding="utf-8")
assert len(js_content) > 0
assert "var nodes" in js_content or "const nodes" in js_content
def test_visualize_flow_structure_json_data():
"""Test that visualization includes valid JSON data in JS file."""
flow = RouterFlow()
structure = build_flow_structure(flow)
html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
html_path = Path(html_file)
js_file = html_path.parent / f"{html_path.stem}_script.js"
js_content = js_file.read_text(encoding="utf-8")
assert "init" in js_content
assert "decide" in js_content
assert "handle_a" in js_content
assert "handle_b" in js_content
assert "router" in js_content.lower()
assert "path_a" in js_content
assert "path_b" in js_content
def test_print_structure_summary():
"""Test printing flow structure summary."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
output = print_structure_summary(structure)
assert "Total nodes:" in output
assert "Total edges:" in output
assert "Start methods:" in output
assert "Router methods:" in output
assert "start_a" in output
assert "start_b" in output
def test_node_metadata_includes_source_info():
"""Test that nodes include source code and line number information."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
for node_name, node_metadata in structure["nodes"].items():
assert node_metadata["source_code"] is not None
assert len(node_metadata["source_code"]) > 0
assert node_metadata["source_start_line"] is not None
assert node_metadata["source_start_line"] > 0
assert node_metadata["source_file"] is not None
assert node_metadata["source_file"].endswith(".py")
def test_node_metadata_includes_method_signature():
"""Test that nodes include method signature information."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
begin_node = structure["nodes"]["begin"]
assert begin_node["method_signature"] is not None
assert "operationId" in begin_node["method_signature"]
assert begin_node["method_signature"]["operationId"] == "begin"
assert "parameters" in begin_node["method_signature"]
assert "returns" in begin_node["method_signature"]
def test_router_node_has_correct_metadata():
"""Test that router nodes have correct type and paths."""
flow = RouterFlow()
structure = build_flow_structure(flow)
router_node = structure["nodes"]["decide"]
assert router_node["type"] == "router"
assert router_node["is_router"] is True
assert router_node["router_paths"] is not None
assert len(router_node["router_paths"]) == 2
assert "path_a" in router_node["router_paths"]
assert "path_b" in router_node["router_paths"]
def test_listen_node_has_trigger_methods():
"""Test that listen nodes include trigger method information."""
flow = RouterFlow()
structure = build_flow_structure(flow)
handle_a_node = structure["nodes"]["handle_a"]
assert handle_a_node["trigger_methods"] is not None
assert "path_a" in handle_a_node["trigger_methods"]
def test_and_condition_node_metadata():
"""Test that AND condition nodes have correct metadata."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
converge_and_node = structure["nodes"]["converge_and"]
assert converge_and_node["condition_type"] == "AND"
assert converge_and_node["trigger_condition"] is not None
assert converge_and_node["trigger_condition"]["type"] == "AND"
assert len(converge_and_node["trigger_condition"]["conditions"]) == 2
def test_visualization_handles_special_characters():
"""Test that visualization properly handles special characters in method names."""
class SpecialCharFlow(Flow):
@start()
def method_with_underscore(self):
return "test"
@listen(method_with_underscore)
def another_method_123(self):
return "done"
flow = SpecialCharFlow()
structure = build_flow_structure(flow)
assert len(structure["nodes"]) == 2
dag_dict = structure_to_dict(structure)
json_str = json.dumps(dag_dict)
assert json_str is not None
assert "method_with_underscore" in json_str
assert "another_method_123" in json_str
def test_empty_flow_structure():
"""Test building structure for a flow with no methods."""
class EmptyFlow(Flow):
pass
flow = EmptyFlow()
structure = build_flow_structure(flow)
assert structure is not None
assert len(structure["nodes"]) == 0
assert len(structure["edges"]) == 0
assert len(structure["start_methods"]) == 0
def test_topological_path_counting():
"""Test that topological path counting is accurate."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
assert len(structure["nodes"]) > 0
assert len(structure["edges"]) > 0
def test_class_signature_metadata():
"""Test that nodes include class signature information."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
for node_name, node_metadata in structure["nodes"].items():
assert node_metadata["class_name"] is not None
assert node_metadata["class_name"] == "SimpleFlow"
assert node_metadata["class_signature"] is not None
assert "SimpleFlow" in node_metadata["class_signature"]
def test_visualization_plot_method():
"""Test that flow.plot() method works."""
flow = SimpleFlow()
html_file = flow.plot("test_plot.html", show=False)
assert os.path.exists(html_file)
def test_router_paths_to_string_conditions():
"""Test that router paths correctly connect to listeners with string conditions."""
class RouterToStringFlow(Flow):
@start()
def init(self):
return "initialized"
@router(init)
def decide(self):
if hasattr(self, "state") and self.state.get("path") == "b":
return "path_b"
return "path_a"
@listen(or_("path_a", "path_b"))
def handle_either(self):
return "handled"
@listen("path_b")
def handle_b_only(self):
return "handled_b"
flow = RouterToStringFlow()
structure = build_flow_structure(flow)
decide_node = structure["nodes"]["decide"]
assert "path_a" in decide_node["router_paths"]
assert "path_b" in decide_node["router_paths"]
router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]]
assert len(router_edges) == 3
edges_to_handle_either = [
edge for edge in router_edges if edge["target"] == "handle_either"
]
assert len(edges_to_handle_either) == 2
edges_to_handle_b_only = [
edge for edge in router_edges if edge["target"] == "handle_b_only"
]
assert len(edges_to_handle_b_only) == 1
def test_router_paths_not_in_and_conditions():
"""Test that router paths don't create edges to AND-nested conditions."""
class RouterAndConditionFlow(Flow):
@start()
def init(self):
return "initialized"
@router(init)
def decide(self):
return "path_a"
@listen("path_a")
def step_1(self):
return "step_1_done"
@listen(and_("path_a", step_1))
def step_2_and(self):
return "step_2_done"
@listen(or_(and_("path_a", step_1), "path_a"))
def step_3_or(self):
return "step_3_done"
flow = RouterAndConditionFlow()
structure = build_flow_structure(flow)
router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]]
targets = [edge["target"] for edge in router_edges]
assert "step_1" in targets
assert "step_3_or" in targets
assert "step_2_and" not in targets

244
uv.lock generated
View File

@@ -295,15 +295,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" },
]
[[package]]
name = "asttokens"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
@@ -1077,7 +1068,6 @@ dependencies = [
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" },
{ name = "pyvis" },
{ name = "regex" },
{ name = "tokenizers" },
{ name = "tomli" },
@@ -1170,7 +1160,6 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "pyjwt", specifier = ">=2.9.0" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "pyvis", specifier = ">=0.3.2" },
{ name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = ">=1.14.3" },
{ name = "regex", specifier = ">=2024.9.11" },
{ name = "tiktoken", marker = "extra == 'embeddings'", specifier = "~=0.8.0" },
@@ -1810,15 +1799,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" },
]
[[package]]
name = "executing"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "faker"
version = "37.11.0"
@@ -2887,90 +2867,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" },
]
[[package]]
name = "ipython"
version = "8.37.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
"python_full_version < '3.11' and platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version < '3.11' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version < '3.11' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux'",
"(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.11' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
{ name = "decorator", marker = "python_full_version < '3.11'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "jedi", marker = "python_full_version < '3.11'" },
{ name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
{ name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
{ name = "pygments", marker = "python_full_version < '3.11'" },
{ name = "stack-data", marker = "python_full_version < '3.11'" },
{ name = "traitlets", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" },
]
[[package]]
name = "ipython"
version = "9.6.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
"python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.12.*' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.11.*' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux'",
"(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.12.*' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux'",
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version == '3.12.*' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.11.*' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux'",
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
{ name = "decorator", marker = "python_full_version >= '3.11'" },
{ name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
{ name = "jedi", marker = "python_full_version >= '3.11'" },
{ name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
{ name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
{ name = "pygments", marker = "python_full_version >= '3.11'" },
{ name = "stack-data", marker = "python_full_version >= '3.11'" },
{ name = "traitlets", marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl", hash = "sha256:5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196", size = 616170, upload-time = "2025-09-29T10:55:47.676Z" },
]
[[package]]
name = "ipython-pygments-lexers"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pygments", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
]
[[package]]
name = "isodate"
version = "0.7.2"
@@ -2980,18 +2876,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
]
[[package]]
name = "jedi"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parso" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -3123,15 +3007,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
]
[[package]]
name = "jsonpickle"
version = "4.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" },
]
[[package]]
name = "jsonpointer"
version = "3.0.0"
@@ -3727,18 +3602,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" },
]
[[package]]
name = "matplotlib-inline"
version = "0.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" },
]
[[package]]
name = "mcp"
version = "1.18.0"
@@ -4998,15 +4861,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" },
]
[[package]]
name = "parso"
version = "0.8.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
@@ -5093,18 +4947,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/e0/52b67d4f00e09e497aec4f71bc44d395605e8ebcea52543242ed34c25ef9/pdfplumber-0.11.7-py3-none-any.whl", hash = "sha256:edd2195cca68bd770da479cf528a737e362968ec2351e62a6c0b71ff612ac25e", size = 60029, upload-time = "2025-06-12T11:30:48.89Z" },
]
[[package]]
name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
]
[[package]]
name = "pi-heif"
version = "0.22.0"
@@ -5344,18 +5186,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -5493,8 +5323,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" },
{ url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" },
{ url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" },
{ url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" },
{ url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" },
{ url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" },
{ url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
@@ -5502,8 +5334,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
@@ -5511,8 +5345,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
@@ -5520,30 +5356,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pure-eval"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
]
[[package]]
name = "py-rust-stemmers"
version = "0.1.5"
@@ -6572,22 +6392,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "pyvis"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "jinja2" },
{ name = "jsonpickle" },
{ name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -7739,20 +7543,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" },
]
[[package]]
name = "stack-data"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asttokens" },
{ name = "executing" },
{ name = "pure-eval" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
[[package]]
name = "stagehand"
version = "0.5.0"
@@ -8075,15 +7865,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "traitlets"
version = "5.14.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
[[package]]
name = "transformers"
version = "4.57.1"
@@ -8754,15 +8535,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "weaviate-client"
version = "4.17.0"