fancy-flow 0.1.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.
Files changed (54) hide show
  1. fancy_flow/__init__.py +97 -0
  2. fancy_flow/capabilities/__init__.py +212 -0
  3. fancy_flow/contracts.py +62 -0
  4. fancy_flow/durable/__init__.py +51 -0
  5. fancy_flow/durable/coordinator.py +330 -0
  6. fancy_flow/durable/frontier.py +145 -0
  7. fancy_flow/durable/human.py +138 -0
  8. fancy_flow/durable/replay.py +126 -0
  9. fancy_flow/durable/retry.py +78 -0
  10. fancy_flow/durable/state.py +192 -0
  11. fancy_flow/engine/__init__.py +5 -0
  12. fancy_flow/engine/runner.py +399 -0
  13. fancy_flow/exceptions.py +49 -0
  14. fancy_flow/executors.py +189 -0
  15. fancy_flow/marketplace/__init__.py +28 -0
  16. fancy_flow/marketplace/manifest.py +425 -0
  17. fancy_flow/nodes/__init__.py +9 -0
  18. fancy_flow/nodes/ai.py +266 -0
  19. fancy_flow/nodes/data.py +97 -0
  20. fancy_flow/nodes/human.py +56 -0
  21. fancy_flow/nodes/io_.py +61 -0
  22. fancy_flow/nodes/logic.py +130 -0
  23. fancy_flow/nodes/output.py +24 -0
  24. fancy_flow/nodes/structural.py +240 -0
  25. fancy_flow/nodes/support/__init__.py +36 -0
  26. fancy_flow/nodes/support/clients.py +182 -0
  27. fancy_flow/nodes/support/deps.py +39 -0
  28. fancy_flow/nodes/support/expr.py +202 -0
  29. fancy_flow/nodes/support/structured.py +221 -0
  30. fancy_flow/nodes/trigger.py +44 -0
  31. fancy_flow/py.typed +0 -0
  32. fancy_flow/registry/__init__.py +21 -0
  33. fancy_flow/registry/builtin.py +1029 -0
  34. fancy_flow/registry/kind_id.py +78 -0
  35. fancy_flow/registry/node_kind.py +203 -0
  36. fancy_flow/registry/registry.py +160 -0
  37. fancy_flow/runtime/__init__.py +24 -0
  38. fancy_flow/runtime/abort.py +42 -0
  39. fancy_flow/runtime/context.py +90 -0
  40. fancy_flow/runtime/events.py +119 -0
  41. fancy_flow/runtime/identity.py +202 -0
  42. fancy_flow/runtime/options.py +63 -0
  43. fancy_flow/runtime/pause.py +119 -0
  44. fancy_flow/runtime/ports.py +30 -0
  45. fancy_flow/schema/__init__.py +16 -0
  46. fancy_flow/schema/graph.py +154 -0
  47. fancy_flow/schema/issues.py +65 -0
  48. fancy_flow/security/__init__.py +5 -0
  49. fancy_flow/security/policy.py +333 -0
  50. fancy_flow/workflow.py +207 -0
  51. fancy_flow-0.1.0.dist-info/METADATA +157 -0
  52. fancy_flow-0.1.0.dist-info/RECORD +54 -0
  53. fancy_flow-0.1.0.dist-info/WHEEL +4 -0
  54. fancy_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
fancy_flow/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """fancy-flow for Python -- the third runtime for fancy-flow workflow graphs.
2
+
3
+ The guarantee, and the only thing that matters: **the same WorkflowSchema JSON
4
+ in produces the same RunResult.outputs out**, on Node, on PHP, and here. This
5
+ is a faithful PORT, not a redesign; behaviour questions are settled against
6
+ ``@particle-academy/fancy-flow`` and ``particle-academy/fancy-flow-php``, and
7
+ parity is asserted by shared fixture tables rather than claimed.
8
+
9
+ Getting started::
10
+
11
+ from fancy_flow import FlowRunner, RunOptions, builtin, import_workflow
12
+
13
+ builtin.register() # install the built-in kinds
14
+ result = import_workflow(schema_json)
15
+ run = FlowRunner().run(
16
+ result.graph,
17
+ builtin.executors(),
18
+ options=RunOptions(initial_inputs={"trigger-1": {"payload": body}}),
19
+ )
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from . import capabilities
25
+ from .contracts import NativeResolver, NodeExecutor, Resolver, TriggerGuard
26
+ from .engine.runner import FlowRunner
27
+ from .exceptions import FlowError, RunAborted, UnsafeGraph
28
+ from .executors import ExecutorRegistry
29
+ from .registry import builtin
30
+ from .registry.node_kind import ConfigField, NodeKind
31
+ from .registry.registry import NodeKindRegistry, default_registry, reset_default_registry
32
+ from .runtime import (
33
+ AbortController,
34
+ AbortSignal,
35
+ ExecutionContext,
36
+ NodeStatus,
37
+ Pause,
38
+ PauseSignal,
39
+ Port,
40
+ RunEvent,
41
+ RunOptions,
42
+ RunResult,
43
+ )
44
+ from .schema import (
45
+ FlowEdge,
46
+ FlowGraph,
47
+ FlowNode,
48
+ ImportIssue,
49
+ ImportResult,
50
+ PortDescriptor,
51
+ WorkflowMetadata,
52
+ )
53
+ from .workflow import SCHEMA_URL, SCHEMA_VERSION, export_workflow, import_workflow, to_json
54
+
55
+ __version__ = "0.1.0"
56
+
57
+ __all__ = [
58
+ "SCHEMA_URL",
59
+ "SCHEMA_VERSION",
60
+ "AbortController",
61
+ "AbortSignal",
62
+ "ConfigField",
63
+ "ExecutionContext",
64
+ "ExecutorRegistry",
65
+ "FlowEdge",
66
+ "FlowError",
67
+ "FlowGraph",
68
+ "FlowNode",
69
+ "FlowRunner",
70
+ "ImportIssue",
71
+ "ImportResult",
72
+ "NativeResolver",
73
+ "NodeExecutor",
74
+ "NodeKind",
75
+ "NodeKindRegistry",
76
+ "NodeStatus",
77
+ "Pause",
78
+ "PauseSignal",
79
+ "Port",
80
+ "PortDescriptor",
81
+ "Resolver",
82
+ "RunAborted",
83
+ "RunEvent",
84
+ "RunOptions",
85
+ "RunResult",
86
+ "TriggerGuard",
87
+ "UnsafeGraph",
88
+ "WorkflowMetadata",
89
+ "__version__",
90
+ "builtin",
91
+ "capabilities",
92
+ "default_registry",
93
+ "export_workflow",
94
+ "import_workflow",
95
+ "reset_default_registry",
96
+ "to_json",
97
+ ]
@@ -0,0 +1,212 @@
1
+ """Host capabilities -- the services core nodes need but must never depend on.
2
+
3
+ A node that imports a provider SDK forces every consumer to install it: a
4
+ workflow app that never calls a model should not inherit an LLM dependency. So
5
+ core declares the CONTRACT and the host supplies the implementation.
6
+
7
+ Two ways in: the module-level setters below (so the framework-free core stays
8
+ usable with no container), or an adapter package that registers on the host's
9
+ behalf at startup.
10
+
11
+ Unlike the PHP twin there is **no auto-detection**. PHP can afford it because
12
+ ``class_exists()`` is free; the Python equivalent is importing a candidate
13
+ package to find out whether it is there, which has side effects, costs start-up
14
+ time, and silently picks a provider the author never named. A missing client
15
+ therefore aborts the node with :func:`llm_unavailable_message`, which says what
16
+ to register -- an outcome the author can act on, rather than a guess they have
17
+ to discover.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from typing import Any, Protocol, runtime_checkable
25
+
26
+ from ..schema.graph import FlowGraph
27
+
28
+ __all__ = [
29
+ "LlmClient",
30
+ "LlmRoute",
31
+ "LlmRouteChoice",
32
+ "LlmRouteRequest",
33
+ "WorkflowResolutionFailure",
34
+ "WorkflowResolver",
35
+ "llm_client",
36
+ "llm_unavailable_message",
37
+ "reset",
38
+ "set_llm_client",
39
+ "set_workflow_resolver",
40
+ "status",
41
+ "workflow_resolver",
42
+ ]
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class LlmRoute:
47
+ """One route a model may choose between.
48
+
49
+ The description is what the model actually reads when deciding, so it is
50
+ the field that determines routing quality.
51
+ """
52
+
53
+ port: str
54
+ description: str | None = None
55
+
56
+ @staticmethod
57
+ def from_dict(raw: dict[str, Any]) -> LlmRoute:
58
+ return LlmRoute(
59
+ port=str(raw.get("port") or "").strip(),
60
+ description=str(raw["description"]) if raw.get("description") is not None else None,
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class LlmRouteRequest:
66
+ prompt: str
67
+ routes: tuple[LlmRoute, ...]
68
+ system: str | None = None
69
+ provider: str | None = None
70
+ model: str | None = None
71
+ credential: str | None = None
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class LlmRouteChoice:
76
+ """The port the model picked, and why.
77
+
78
+ ``reason`` travels with the value down the graph, so a completed run
79
+ explains itself without the model call being replayed.
80
+ """
81
+
82
+ port: str
83
+ reason: str | None = None
84
+
85
+
86
+ @runtime_checkable
87
+ class LlmClient(Protocol):
88
+ """The only thing core asks of an LLM: given routes, pick one.
89
+
90
+ ``llm_router`` is a shuttle, not an engine. It carries the routes out to
91
+ whatever the host registered and carries the choice back -- no provider
92
+ SDK, no prompt engineering, no response parsing, no retry policy. That is
93
+ what lets an opinionated node ship as a builtin without every consumer
94
+ inheriting an LLM dependency.
95
+
96
+ Implementations should constrain the model to the declared ports
97
+ (structured output / enum) rather than parsing a port name out of a
98
+ sentence.
99
+ """
100
+
101
+ def choose_route(
102
+ self, request: LlmRouteRequest
103
+ ) -> LlmRouteChoice: ... # pragma: no cover - protocol
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class WorkflowResolutionFailure:
108
+ """Why a ``subflow`` reference could not be honoured.
109
+
110
+ A version mismatch and a missing workflow want different errors: reporting
111
+ a mismatch as "not found" sends an author looking for a workflow that is
112
+ sitting right there.
113
+ """
114
+
115
+ reason: str
116
+ available: int | None = None
117
+ message: str | None = None
118
+
119
+ VERSION_MISMATCH = "version-mismatch"
120
+ NOT_FOUND = "not-found"
121
+
122
+ @property
123
+ def is_version_mismatch(self) -> bool:
124
+ return self.reason == self.VERSION_MISMATCH
125
+
126
+
127
+ @runtime_checkable
128
+ class WorkflowResolver(Protocol):
129
+ """Resolve a workflow reference to a runnable graph.
130
+
131
+ ``subflow`` NAMES another workflow rather than embedding it, so the host
132
+ owns where workflows live -- a database, a file, an API.
133
+
134
+ ``version`` is a parameter rather than part of the reference string because
135
+ a stringly-typed protocol (``invoice-triage@3``) is one every host invents
136
+ differently. A workflow another workflow depends on is an INTERFACE, and
137
+ interfaces need pins: without one, a parent goes on calling
138
+ ``invoice-triage``, someone edits that child, and the parent runs different
139
+ logic having reported success the whole time.
140
+ """
141
+
142
+ def resolve(
143
+ self, ref: str, version: int | None = None
144
+ ) -> FlowGraph | WorkflowResolutionFailure | None: ... # pragma: no cover - protocol
145
+
146
+
147
+ # -- module state --------------------------------------------------------
148
+
149
+ _llm_client: LlmClient | None = None
150
+ _workflow_resolver: WorkflowResolver | None = None
151
+
152
+
153
+ def set_llm_client(client: LlmClient | None) -> Callable[[], None]:
154
+ """Install the host's LLM client. Returns an unregister callable."""
155
+ global _llm_client
156
+ _llm_client = client
157
+
158
+ def unregister() -> None:
159
+ global _llm_client
160
+ if _llm_client is client:
161
+ _llm_client = None
162
+
163
+ return unregister
164
+
165
+
166
+ def llm_client() -> LlmClient | None:
167
+ return _llm_client
168
+
169
+
170
+ def llm_unavailable_message() -> str:
171
+ """Why no client is available, phrased as what to do about it."""
172
+ return (
173
+ "No LLM client is registered, so llm_router cannot ask a model which route to "
174
+ "take. Register one with fancy_flow.capabilities.set_llm_client(client) - any "
175
+ "object with choose_route(LlmRouteRequest) -> LlmRouteChoice will do."
176
+ )
177
+
178
+
179
+ def set_workflow_resolver(resolver: WorkflowResolver | None) -> Callable[[], None]:
180
+ """Install the host's workflow resolver. Returns an unregister callable."""
181
+ global _workflow_resolver
182
+ _workflow_resolver = resolver
183
+
184
+ def unregister() -> None:
185
+ global _workflow_resolver
186
+ if _workflow_resolver is resolver:
187
+ _workflow_resolver = None
188
+
189
+ return unregister
190
+
191
+
192
+ def workflow_resolver() -> WorkflowResolver | None:
193
+ return _workflow_resolver
194
+
195
+
196
+ def status() -> dict[str, bool]:
197
+ """Which capabilities are currently satisfied.
198
+
199
+ Exists so a host -- or an agent over MCP -- can answer "what does this
200
+ graph need that I have not wired?" BEFORE a run fails halfway through.
201
+ """
202
+ return {
203
+ "llm": _llm_client is not None,
204
+ "workflow_resolver": _workflow_resolver is not None,
205
+ }
206
+
207
+
208
+ def reset() -> None:
209
+ """Clear everything. Test isolation."""
210
+ global _llm_client, _workflow_resolver
211
+ _llm_client = None
212
+ _workflow_resolver = None
@@ -0,0 +1,62 @@
1
+ """The seams a host plugs into.
2
+
3
+ Structural :class:`typing.Protocol` rather than nominal base classes, because a
4
+ host's existing service should be usable as an executor without inheriting from
5
+ us. That is the same freedom the PHP twin gets from accepting a callable, an
6
+ interface implementation, or a class-string interchangeably.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from .runtime.context import ExecutionContext
14
+
15
+ __all__ = ["NodeExecutor", "Resolver", "TriggerGuard"]
16
+
17
+
18
+ @runtime_checkable
19
+ class NodeExecutor(Protocol):
20
+ """Behaviour for one node kind.
21
+
22
+ An executor may equally be a plain callable taking the context; this
23
+ protocol exists for the class-shaped case, which is what constructor
24
+ injection wants.
25
+ """
26
+
27
+ def execute(self, ctx: ExecutionContext) -> Any: # pragma: no cover - protocol
28
+ ...
29
+
30
+
31
+ @runtime_checkable
32
+ class Resolver(Protocol):
33
+ """Turns a class into an instance.
34
+
35
+ The default calls the class with no arguments. A host with a DI container
36
+ supplies its own so executors get constructor injection -- the analogue of
37
+ the PHP twin's ``ContainerResolver``.
38
+ """
39
+
40
+ def make(self, cls: type) -> Any: # pragma: no cover - protocol
41
+ ...
42
+
43
+
44
+ @runtime_checkable
45
+ class TriggerGuard(Protocol):
46
+ """The precondition a queued cohort run re-checks just before it starts.
47
+
48
+ Fails CLOSED by design: when the guard cannot answer, the run does not
49
+ start. Several runs fired by one event are serialized, and each re-asks
50
+ whether it still should happen -- because by the time the third one is
51
+ picked up, the first two may have made it wrong.
52
+ """
53
+
54
+ def should_run(self, run_key: str, context: dict[str, Any]) -> bool: # pragma: no cover
55
+ ...
56
+
57
+
58
+ class NativeResolver:
59
+ """The default :class:`Resolver` -- constructs with no arguments."""
60
+
61
+ def make(self, cls: type) -> Any:
62
+ return cls()
@@ -0,0 +1,51 @@
1
+ """Durable, resumable runs -- with no queue library anywhere in sight.
2
+
3
+ The research behind this is written up in the envelope's
4
+ ``.ai/plans/fancy-flow-py.md``; the short version is that a JSON-graph engine
5
+ wants **checkpoint-per-node keyed by node id**, not Temporal-style event-sourced
6
+ replay. Replay exists to police arbitrary user code for non-determinism; an
7
+ interpreter over a declarative graph is deterministic by construction, so the
8
+ sandbox, the history limits and the versioning tax buy nothing. And node-id
9
+ keying survives a graph being edited while a run is parked on an approval,
10
+ where an ordinal-keyed checkpoint cannot.
11
+
12
+ So durability lives here, in the pure core:
13
+
14
+ - :mod:`.state` what a run remembers, and the claim contract a database implements
15
+ - :mod:`.frontier` which nodes are unblocked, restated from the engine's own rule
16
+ - :mod:`.replay` run one node THROUGH the engine, never around it
17
+ - :mod:`.retry` how many attempts a node gets, per node
18
+ - :mod:`.human` gates that pause and cannot be walked past
19
+ - :mod:`.coordinator` the two operations a queue adapter dispatches
20
+
21
+ A queue adapter supplies transport and nothing else.
22
+ """
23
+
24
+ from .coordinator import Coordinator, DurableRunResult, NodeOutcome
25
+ from .frontier import Frontier, FrontierResult
26
+ from .human import DurableApproval, DurableUserInput, NotAwaitingHuman, Submissions
27
+ from .replay import BOUNDARY, ReplayResult, is_boundary, replay_up_to
28
+ from .retry import UNSAFE_TO_REPLAY, RetryPolicy
29
+ from .state import InMemoryClaimStore, NodeClaimStore, NodeRunStatus, NodeState
30
+
31
+ __all__ = [
32
+ "BOUNDARY",
33
+ "UNSAFE_TO_REPLAY",
34
+ "Coordinator",
35
+ "DurableApproval",
36
+ "DurableRunResult",
37
+ "DurableUserInput",
38
+ "Frontier",
39
+ "FrontierResult",
40
+ "InMemoryClaimStore",
41
+ "NodeClaimStore",
42
+ "NodeOutcome",
43
+ "NodeRunStatus",
44
+ "NodeState",
45
+ "NotAwaitingHuman",
46
+ "ReplayResult",
47
+ "RetryPolicy",
48
+ "Submissions",
49
+ "is_boundary",
50
+ "replay_up_to",
51
+ ]