pydantic-graph 0.2.2__py3-none-any.whl → 1.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pydantic_graph/_utils.py CHANGED
@@ -1,18 +1,23 @@
1
1
  from __future__ import annotations as _annotations
2
2
 
3
3
  import asyncio
4
+ import inspect
4
5
  import types
6
+ import warnings
7
+ from collections.abc import Callable, Generator
8
+ from contextlib import contextmanager
5
9
  from functools import partial
6
- from typing import TYPE_CHECKING, Any, Callable, TypeVar
10
+ from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, get_args, get_origin
7
11
 
8
- from logfire_api import LogfireSpan
9
- from typing_extensions import ParamSpec, TypeAlias, TypeIs, get_args, get_origin
12
+ from logfire_api import Logfire, LogfireSpan
13
+ from typing_extensions import ParamSpec, TypeIs
10
14
  from typing_inspection import typing_objects
11
15
  from typing_inspection.introspection import is_union_origin
12
16
 
13
17
  if TYPE_CHECKING:
14
18
  from opentelemetry.trace import Span
15
19
 
20
+ _logfire = Logfire(otel_scope='pydantic-graph')
16
21
 
17
22
  AbstractSpan: TypeAlias = 'LogfireSpan | Span'
18
23
 
@@ -58,7 +63,7 @@ def get_union_args(tp: Any) -> tuple[Any, ...]:
58
63
  """Extract the arguments of a Union type if `response_type` is a union, otherwise return an empty tuple."""
59
64
  # similar to `pydantic_ai_slim/pydantic_ai/_result.py:get_union_args`
60
65
  if typing_objects.is_typealiastype(tp):
61
- tp = tp.__value__
66
+ tp = tp.__value__ # pragma: no cover
62
67
 
63
68
  origin = get_origin(tp)
64
69
  if is_union_origin(origin):
@@ -96,8 +101,8 @@ def get_parent_namespace(frame: types.FrameType | None) -> dict[str, Any] | None
96
101
  If the graph is defined with generics `Graph[a, b]` then another frame is inserted, and we have to skip that
97
102
  to get the correct namespace.
98
103
  """
99
- if frame is not None:
100
- if back := frame.f_back:
104
+ if frame is not None: # pragma: no branch
105
+ if back := frame.f_back: # pragma: no branch
101
106
  if back.f_globals.get('__name__') == 'typing':
102
107
  # If the class calling this function is generic, explicitly parameterizing the class
103
108
  # results in a `typing._GenericAlias` instance, which proxies instantiation calls to the
@@ -135,3 +140,65 @@ async def run_in_executor(func: Callable[_P, _R], *args: _P.args, **kwargs: _P.k
135
140
  return await asyncio.get_running_loop().run_in_executor(None, partial(func, *args, **kwargs))
136
141
  else:
137
142
  return await asyncio.get_running_loop().run_in_executor(None, func, *args) # type: ignore
143
+
144
+
145
+ try:
146
+ from logfire._internal.config import (
147
+ LogfireNotConfiguredWarning, # pyright: ignore[reportAssignmentType,reportPrivateImportUsage]
148
+ )
149
+ except ImportError: # pragma: lax no cover
150
+
151
+ class LogfireNotConfiguredWarning(UserWarning):
152
+ pass
153
+
154
+
155
+ if TYPE_CHECKING:
156
+ logfire_span = _logfire.span
157
+ else:
158
+
159
+ @contextmanager
160
+ def logfire_span(*args: Any, **kwargs: Any) -> Generator[LogfireSpan, None, None]:
161
+ """Create a Logfire span without warning if logfire is not configured."""
162
+ # TODO: Remove once Logfire has the ability to suppress this warning from non-user code
163
+ with warnings.catch_warnings():
164
+ warnings.filterwarnings('ignore', category=LogfireNotConfiguredWarning)
165
+ with _logfire.span(*args, **kwargs) as span:
166
+ yield span
167
+
168
+
169
+ def infer_obj_name(obj: Any, *, depth: int) -> str | None:
170
+ """Infer the variable name of an object from the calling frame's scope.
171
+
172
+ This function examines the call stack to find what variable name was used
173
+ for the given object in the calling scope. This is useful for automatic
174
+ naming of objects based on their variable names.
175
+
176
+ Args:
177
+ obj: The object whose variable name to infer.
178
+ depth: Number of stack frames to traverse upward from the current frame.
179
+
180
+ Returns:
181
+ The inferred variable name if found, None otherwise.
182
+
183
+ Example:
184
+ Usage should generally look like `infer_name(self, depth=2)` or similar.
185
+ """
186
+ target_frame = inspect.currentframe()
187
+ if target_frame is None:
188
+ return None # pragma: no cover
189
+ for _ in range(depth):
190
+ target_frame = target_frame.f_back
191
+ if target_frame is None:
192
+ return None
193
+
194
+ for name, item in target_frame.f_locals.items():
195
+ if item is obj:
196
+ return name
197
+
198
+ if target_frame.f_locals != target_frame.f_globals: # pragma: no branch
199
+ # if we couldn't find the agent in locals and globals are a different dict, try globals
200
+ for name, item in target_frame.f_globals.items():
201
+ if item is obj:
202
+ return name
203
+
204
+ return None
@@ -0,0 +1,25 @@
1
+ """The next version of the pydantic-graph framework with enhanced graph execution capabilities.
2
+
3
+ This module provides a parallel control flow graph execution framework with support for:
4
+ - 'Step' nodes for task execution
5
+ - 'Decision' nodes for conditional branching
6
+ - 'Fork' nodes for parallel execution coordination
7
+ - 'Join' nodes and 'Reducer's for re-joining parallel executions
8
+ - Mermaid diagram generation for graph visualization
9
+ """
10
+
11
+ from .graph import Graph
12
+ from .graph_builder import GraphBuilder
13
+ from .node import EndNode, StartNode
14
+ from .step import StepContext, StepNode
15
+ from .util import TypeExpression
16
+
17
+ __all__ = (
18
+ 'EndNode',
19
+ 'Graph',
20
+ 'GraphBuilder',
21
+ 'StartNode',
22
+ 'StepContext',
23
+ 'StepNode',
24
+ 'TypeExpression',
25
+ )
@@ -0,0 +1,276 @@
1
+ """Decision node implementation for conditional branching in graph execution.
2
+
3
+ This module provides the Decision node type and related classes for implementing
4
+ conditional branching logic in parallel control flow graphs. Decision nodes allow the graph
5
+ to choose different execution paths based on runtime conditions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from collections.abc import AsyncIterable, Callable, Iterable, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any, Generic, get_origin
14
+
15
+ from typing_extensions import Never, Self, TypeVar
16
+
17
+ from pydantic_graph import BaseNode
18
+ from pydantic_graph.beta.id_types import NodeID
19
+ from pydantic_graph.beta.paths import Path, PathBuilder, TransformFunction
20
+ from pydantic_graph.beta.step import NodeStep
21
+ from pydantic_graph.beta.util import TypeOrTypeExpression
22
+ from pydantic_graph.exceptions import GraphBuildingError
23
+
24
+ if TYPE_CHECKING:
25
+ from pydantic_graph.beta.node_types import AnyDestinationNode, DestinationNode
26
+
27
+ StateT = TypeVar('StateT', infer_variance=True)
28
+ """Type variable for graph state."""
29
+
30
+ DepsT = TypeVar('DepsT', infer_variance=True)
31
+ """Type variable for graph dependencies."""
32
+
33
+ HandledT = TypeVar('HandledT', infer_variance=True)
34
+ """Type variable used to track types handled by the branches of a Decision."""
35
+
36
+ T = TypeVar('T', infer_variance=True)
37
+ """Generic type variable."""
38
+
39
+
40
+ @dataclass(kw_only=True)
41
+ class Decision(Generic[StateT, DepsT, HandledT]):
42
+ """Decision node for conditional branching in graph execution.
43
+
44
+ A Decision node evaluates conditions and routes execution to different
45
+ branches based on the input data type or custom matching logic.
46
+ """
47
+
48
+ id: NodeID
49
+ """Unique identifier for this decision node."""
50
+
51
+ branches: list[DecisionBranch[Any]]
52
+ """List of branches that can be taken from this decision."""
53
+
54
+ note: str | None
55
+ """Optional documentation note for this decision."""
56
+
57
+ def branch(self, branch: DecisionBranch[T]) -> Decision[StateT, DepsT, HandledT | T]:
58
+ """Add a new branch to this decision.
59
+
60
+ Args:
61
+ branch: The branch to add to this decision.
62
+
63
+ Returns:
64
+ A new Decision with the additional branch.
65
+ """
66
+ return Decision(id=self.id, branches=self.branches + [branch], note=self.note)
67
+
68
+ def _force_handled_contravariant(self, inputs: HandledT) -> Never: # pragma: no cover
69
+ """Forces this type to be contravariant in the HandledT type variable.
70
+
71
+ This is an implementation detail of how we can type-check that all possible input types have
72
+ been exhaustively covered.
73
+
74
+ Args:
75
+ inputs: Input data of handled types.
76
+
77
+ Raises:
78
+ RuntimeError: Always, as this method should never be executed.
79
+ """
80
+ raise RuntimeError('This method should never be called, it is just defined for typing purposes.')
81
+
82
+
83
+ SourceT = TypeVar('SourceT', infer_variance=True)
84
+ """Type variable for source data for a DecisionBranch."""
85
+
86
+
87
+ @dataclass
88
+ class DecisionBranch(Generic[SourceT]):
89
+ """Represents a single branch within a decision node.
90
+
91
+ Each branch defines the conditions under which it should be taken
92
+ and the path to follow when those conditions are met.
93
+
94
+ Note: with the current design, it is actually _critical_ that this class is invariant in SourceT for the sake
95
+ of type-checking that inputs to a Decision are actually handled. See the `# type: ignore` comment in
96
+ `tests.graph.beta.test_graph_edge_cases.test_decision_no_matching_branch` for an example of how this works.
97
+ """
98
+
99
+ source: TypeOrTypeExpression[SourceT]
100
+ """The expected type of data for this branch.
101
+
102
+ This is necessary for exhaustiveness-checking when handling the inputs to a decision node."""
103
+
104
+ matches: Callable[[Any], bool] | None
105
+ """An optional predicate function used to determine whether input data matches this branch.
106
+
107
+ If `None`, default logic is used which attempts to check the value for type-compatibility with the `source` type:
108
+ * If `source` is `Any` or `object`, the branch will always match
109
+ * If `source` is a `Literal` type, this branch will match if the value is one of the parametrizing literal values
110
+ * If `source` is any other type, the value will be checked for matching using `isinstance`
111
+
112
+ Inputs are tested against each branch of a decision node in order, and the path of the first matching branch is
113
+ used to handle the input value.
114
+ """
115
+
116
+ path: Path
117
+ """The execution path to follow when an input value matches this branch of a decision node.
118
+
119
+ This can include transforming, mapping, and broadcasting the output before sending to the next node or nodes.
120
+
121
+ The path can also include position-aware labels which are used when generating mermaid diagrams."""
122
+
123
+ destinations: list[AnyDestinationNode]
124
+ """The destination nodes that can be referenced by DestinationMarker in the path."""
125
+
126
+
127
+ OutputT = TypeVar('OutputT', infer_variance=True)
128
+ """Type variable for the output data of a node."""
129
+
130
+ NewOutputT = TypeVar('NewOutputT', infer_variance=True)
131
+ """Type variable for transformed output."""
132
+
133
+
134
+ @dataclass(init=False)
135
+ class DecisionBranchBuilder(Generic[StateT, DepsT, OutputT, SourceT, HandledT]):
136
+ """Builder for constructing decision branches with fluent API.
137
+
138
+ This builder provides methods to configure branches with destinations,
139
+ forks, and transformations in a type-safe manner.
140
+
141
+ Instances of this class should be created using [`GraphBuilder.match`][pydantic_graph.beta.graph_builder.GraphBuilder],
142
+ not created directly.
143
+ """
144
+
145
+ _decision: Decision[StateT, DepsT, HandledT]
146
+ """The parent decision node."""
147
+ _source: TypeOrTypeExpression[SourceT]
148
+ """The expected source type for this branch."""
149
+ _matches: Callable[[Any], bool] | None
150
+ """Optional matching predicate."""
151
+
152
+ _path_builder: PathBuilder[StateT, DepsT, OutputT]
153
+ """Builder for the execution path."""
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ decision: Decision[StateT, DepsT, HandledT],
159
+ source: TypeOrTypeExpression[SourceT],
160
+ matches: Callable[[Any], bool] | None,
161
+ path_builder: PathBuilder[StateT, DepsT, OutputT],
162
+ ):
163
+ # This manually-defined initializer is necessary due to https://github.com/python/mypy/issues/17623.
164
+ self._decision = decision
165
+ self._source = source
166
+ self._matches = matches
167
+ self._path_builder = path_builder
168
+
169
+ def to(
170
+ self,
171
+ destination: DestinationNode[StateT, DepsT, OutputT] | type[BaseNode[StateT, DepsT, Any]],
172
+ /,
173
+ *extra_destinations: DestinationNode[StateT, DepsT, OutputT] | type[BaseNode[StateT, DepsT, Any]],
174
+ fork_id: str | None = None,
175
+ ) -> DecisionBranch[SourceT]:
176
+ """Set the destination(s) for this branch.
177
+
178
+ Args:
179
+ destination: The primary destination node.
180
+ *extra_destinations: Additional destination nodes.
181
+ fork_id: Optional node ID to use for the resulting broadcast fork if multiple destinations are provided.
182
+
183
+ Returns:
184
+ A completed DecisionBranch with the specified destinations.
185
+ """
186
+ destination = get_origin(destination) or destination
187
+ extra_destinations = tuple(get_origin(d) or d for d in extra_destinations)
188
+ destinations = [(NodeStep(d) if inspect.isclass(d) else d) for d in (destination, *extra_destinations)]
189
+ return DecisionBranch(
190
+ source=self._source,
191
+ matches=self._matches,
192
+ path=self._path_builder.to(*destinations, fork_id=fork_id),
193
+ destinations=destinations,
194
+ )
195
+
196
+ def broadcast(
197
+ self, get_forks: Callable[[Self], Sequence[DecisionBranch[SourceT]]], /, *, fork_id: str | None = None
198
+ ) -> DecisionBranch[SourceT]:
199
+ """Broadcast this decision branch into multiple destinations.
200
+
201
+ Args:
202
+ get_forks: The callback that will return a sequence of decision branches to broadcast to.
203
+ fork_id: Optional node ID to use for the resulting broadcast fork.
204
+
205
+ Returns:
206
+ A completed DecisionBranch with the specified destinations.
207
+ """
208
+ fork_decision_branches = get_forks(self)
209
+ new_paths = [b.path for b in fork_decision_branches]
210
+ if not new_paths:
211
+ raise GraphBuildingError(f'The call to {get_forks} returned no branches, but must return at least one.')
212
+ path = self._path_builder.broadcast(new_paths, fork_id=fork_id)
213
+ destinations = [d for fdp in fork_decision_branches for d in fdp.destinations]
214
+ return DecisionBranch(source=self._source, matches=self._matches, path=path, destinations=destinations)
215
+
216
+ def transform(
217
+ self, func: TransformFunction[StateT, DepsT, OutputT, NewOutputT], /
218
+ ) -> DecisionBranchBuilder[StateT, DepsT, NewOutputT, SourceT, HandledT]:
219
+ """Apply a transformation to the branch's output.
220
+
221
+ Args:
222
+ func: Transformation function to apply.
223
+
224
+ Returns:
225
+ A new DecisionBranchBuilder where the provided transform is applied prior to generating the final output.
226
+ """
227
+ return DecisionBranchBuilder(
228
+ decision=self._decision,
229
+ source=self._source,
230
+ matches=self._matches,
231
+ path_builder=self._path_builder.transform(func),
232
+ )
233
+
234
+ def map(
235
+ self: DecisionBranchBuilder[StateT, DepsT, Iterable[T], SourceT, HandledT]
236
+ | DecisionBranchBuilder[StateT, DepsT, AsyncIterable[T], SourceT, HandledT],
237
+ *,
238
+ fork_id: str | None = None,
239
+ downstream_join_id: str | None = None,
240
+ ) -> DecisionBranchBuilder[StateT, DepsT, T, SourceT, HandledT]:
241
+ """Spread the branch's output.
242
+
243
+ To do this, the current output must be iterable, and any subsequent steps in the path being built for this
244
+ branch will be applied to each item of the current output in parallel.
245
+
246
+ Args:
247
+ fork_id: Optional ID for the fork, defaults to a generated value
248
+ downstream_join_id: Optional ID of a downstream join node which is involved when mapping empty iterables
249
+
250
+ Returns:
251
+ A new DecisionBranchBuilder where mapping is performed prior to generating the final output.
252
+ """
253
+ return DecisionBranchBuilder(
254
+ decision=self._decision,
255
+ source=self._source,
256
+ matches=self._matches,
257
+ path_builder=self._path_builder.map(fork_id=fork_id, downstream_join_id=downstream_join_id),
258
+ )
259
+
260
+ def label(self, label: str) -> DecisionBranchBuilder[StateT, DepsT, OutputT, SourceT, HandledT]:
261
+ """Apply a label to the branch at the current point in the path being built.
262
+
263
+ These labels are only used in generated mermaid diagrams.
264
+
265
+ Args:
266
+ label: The label to apply.
267
+
268
+ Returns:
269
+ A new DecisionBranchBuilder where the label has been applied at the end of the current path being built.
270
+ """
271
+ return DecisionBranchBuilder(
272
+ decision=self._decision,
273
+ source=self._source,
274
+ matches=self._matches,
275
+ path_builder=self._path_builder.label(label),
276
+ )