forktex-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.
@@ -0,0 +1,106 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Postgres-native durable workflow execution, built on the ``forktex`` substrate.
25
+
26
+ A workflow is an ordered list of steps. Declare one of two ways::
27
+
28
+ flow = Flow(database_url="postgresql+asyncpg://...")
29
+
30
+ @flow.scheduled("cloud.backup.create", version=1, cron="0 2 * * *", state=BackupState)
31
+ async def backup_create(ctx: Ctx, state: BackupState) -> dict: ...
32
+
33
+ @flow.pipeline("cloud.deploy.apply", version=4, state=DeployState)
34
+ class DeployApply:
35
+ steps = [load_manifest, provision, step(dns_setup, when=lambda s: s["dns_enabled"])]
36
+
37
+ Dispatch and read::
38
+
39
+ instance = await flow.run("cloud.deploy.apply", state={...}, metadata={...})
40
+ page = await flow.query().workflow("cloud.deploy.apply").status("running").fetch()
41
+
42
+ A step is ``async def fn(ctx: Ctx, state: dict) -> dict`` and returns the *delta*
43
+ it wants merged, not the whole state. How a delta merges is declared on the
44
+ state class::
45
+
46
+ class DeployState(TypedDict):
47
+ logs: Annotated[list[str], operator.add] # accumulates across steps
48
+ server_id: str # last write wins
49
+
50
+ Every step is memoised on ``(run_id, step, input state)``, so a run picked up
51
+ after a crash re-executes from the top and skips straight past the work that
52
+ already happened.
53
+ """
54
+
55
+ from forktex_flow.domain.config import RuntimeConfig
56
+ from forktex_flow.domain.node import StepSpec, step
57
+ from forktex_flow.domain.state import apply_state_update, extract_reducers
58
+ from forktex_flow.domain.types import (
59
+ TERMINAL_STATUSES,
60
+ RunInfo,
61
+ RunStatus,
62
+ RunSummary,
63
+ StepRunInfo,
64
+ StepStatus,
65
+ )
66
+ from forktex_flow.errors import FlowError, StepFailed, WorkflowCancelled, WorkflowFailed
67
+ from forktex_flow.extension import FlowExtension
68
+ from forktex_flow.flow import Flow
69
+ from forktex_flow.persist.migrations import apply_migrations
70
+ from forktex_flow.read.instance import (
71
+ InstancePage,
72
+ InstanceQuery,
73
+ NodeInstance,
74
+ WorkflowInstance,
75
+ )
76
+ from forktex_flow.runtime.ctx import Ctx
77
+ from forktex_flow.testing import isolated_flow, make_ctx, run_to_completion
78
+
79
+ __all__ = [
80
+ "TERMINAL_STATUSES",
81
+ "Ctx",
82
+ "Flow",
83
+ "FlowError",
84
+ "FlowExtension",
85
+ "InstancePage",
86
+ "InstanceQuery",
87
+ "NodeInstance",
88
+ "RunInfo",
89
+ "RunStatus",
90
+ "RunSummary",
91
+ "RuntimeConfig",
92
+ "StepFailed",
93
+ "StepRunInfo",
94
+ "StepSpec",
95
+ "StepStatus",
96
+ "WorkflowCancelled",
97
+ "WorkflowFailed",
98
+ "WorkflowInstance",
99
+ "apply_migrations",
100
+ "apply_state_update",
101
+ "extract_reducers",
102
+ "isolated_flow",
103
+ "make_ctx",
104
+ "run_to_completion",
105
+ "step",
106
+ ]
@@ -0,0 +1,76 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Flow's vocabulary: frozen value objects and the declarative workflow shapes.
25
+
26
+ The bottom of the package's DAG — nothing here imports anything else from
27
+ `forktex_flow`, and nothing here performs I/O. A reader can learn what a workflow
28
+ *is* from this layer alone, without meeting a session or an engine.
29
+
30
+ Four modules, split by what they describe: `types` the run/step row shapes and
31
+ statuses, `definition` the compiled workflow (an ordered list of steps), `node`
32
+ the authoring decorator, and `state` the reducer that merges a step's partial
33
+ update into the run state.
34
+ """
35
+
36
+ from forktex_flow.domain.definition import (
37
+ NodeDef,
38
+ NodeFn,
39
+ WhenFn,
40
+ WorkflowDefinition,
41
+ )
42
+ from forktex_flow.domain.node import (
43
+ StepMeta,
44
+ StepSpec,
45
+ has_step_meta,
46
+ step,
47
+ step_meta,
48
+ )
49
+ from forktex_flow.domain.state import ReducerFn, apply_state_update, extract_reducers
50
+ from forktex_flow.domain.types import (
51
+ TERMINAL_STATUSES,
52
+ RunInfo,
53
+ RunStatus,
54
+ StepRunInfo,
55
+ StepStatus,
56
+ )
57
+
58
+ __all__ = [
59
+ "TERMINAL_STATUSES",
60
+ "NodeDef",
61
+ "NodeFn",
62
+ "ReducerFn",
63
+ "RunInfo",
64
+ "RunStatus",
65
+ "StepMeta",
66
+ "StepRunInfo",
67
+ "StepSpec",
68
+ "StepStatus",
69
+ "WhenFn",
70
+ "WorkflowDefinition",
71
+ "apply_state_update",
72
+ "extract_reducers",
73
+ "has_step_meta",
74
+ "step",
75
+ "step_meta",
76
+ ]
@@ -0,0 +1,88 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Execution tuning — the knobs the driver and executor read.
25
+
26
+ A frozen value object rather than attributes on ``Flow``, because it is the
27
+ whole reason the lower layers used to take the facade: they needed a handful of
28
+ numbers and took the entire engine to get them. Pure data, no I/O, so it sits at the
29
+ bottom of the DAG where every layer can read it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from forktex.error import BadRequestError
35
+ from forktex.types import BaseValueObject
36
+ from pydantic import model_validator
37
+
38
+
39
+ class RuntimeConfig(BaseValueObject):
40
+ """How hard the driver polls, and how patient it is with a stalled step.
41
+
42
+ Attributes:
43
+ poll_interval: Seconds between driver ticks when it holds the lock.
44
+ election_interval: Seconds a worker waits before standing for election
45
+ again after losing one. Bounds how long a takeover
46
+ takes after the leader dies: too long and work sits
47
+ idle, too short and every worker polls the lock.
48
+ heartbeat_interval: Seconds between heartbeat writes on a running step.
49
+ stale_threshold: Seconds without a heartbeat after which a step is
50
+ considered abandoned and may be reclaimed. Must
51
+ exceed ``heartbeat_interval``, or a healthy step
52
+ gets reclaimed out from under itself.
53
+ max_attempts: Default attempts per step, when ``@step`` does not
54
+ override it.
55
+ backoff: Default per-attempt delay in seconds; the last entry
56
+ is reused once attempts run past its length.
57
+ leader_lock_key: The advisory-lock key the driver competes on.
58
+ """
59
+
60
+ poll_interval: float = 1.0
61
+ election_interval: float = 2.0
62
+ heartbeat_interval: float = 10.0
63
+ stale_threshold: float = 60.0
64
+ max_attempts: int = 3
65
+ backoff: tuple[float, ...] = (30.0, 120.0, 300.0)
66
+ leader_lock_key: int = 0
67
+
68
+ @model_validator(mode="after")
69
+ def _check(self) -> RuntimeConfig:
70
+ if self.max_attempts < 1:
71
+ raise BadRequestError("max_attempts must be >= 1")
72
+ if not self.backoff:
73
+ raise BadRequestError("backoff must be a non-empty tuple")
74
+ if self.poll_interval <= 0:
75
+ raise BadRequestError("poll_interval must be > 0")
76
+ if self.election_interval <= 0:
77
+ raise BadRequestError("election_interval must be > 0")
78
+ if self.stale_threshold <= self.heartbeat_interval:
79
+ # Otherwise the reclaim scan finds a step whose heartbeat is merely
80
+ # *due* and hands it to a second worker, which is how one step body
81
+ # ends up running twice.
82
+ raise BadRequestError(
83
+ f"stale_threshold ({self.stale_threshold}) must exceed heartbeat_interval ({self.heartbeat_interval})"
84
+ )
85
+ return self
86
+
87
+
88
+ __all__ = ["RuntimeConfig"]
@@ -0,0 +1,125 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """WorkflowDefinition — the internal model every decorator compiles to.
25
+
26
+ A workflow is an **ordered list of steps**. That is the whole topology: there is
27
+ no edge set, no router, no START/END sentinel and no reachability analysis,
28
+ because a pipeline's order *is* its graph and a scheduled workflow is a pipeline
29
+ of one. Branching is expressed by a step's ``when_fn`` — a pure predicate over
30
+ the state dict, evaluated immediately before dispatch — which is strictly weaker
31
+ than a router and strictly easier to reason about: a run visits every node once,
32
+ in order, executing the ones whose guard passes.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections.abc import Awaitable, Callable
38
+ from typing import Any
39
+
40
+ from forktex.error import BadRequestError
41
+ from forktex.types import BaseValueObject
42
+ from pydantic import BaseModel, Field, model_validator
43
+
44
+ from forktex_flow.domain.state import ReducerFn
45
+
46
+ #: ``async def fn(ctx: Ctx, state: dict[str, Any]) -> dict[str, Any]`` — a step
47
+ #: returns the *delta* it wants merged into state, not the whole state.
48
+ NodeFn = Callable[[Any, dict[str, Any]], Awaitable[dict[str, Any]]]
49
+
50
+ #: A guard evaluated against the current state before a node is dispatched.
51
+ WhenFn = Callable[[dict[str, Any]], bool]
52
+
53
+
54
+ class NodeDef(BaseValueObject):
55
+ """One step in the workflow.
56
+
57
+ ``when_fn`` is evaluated against the current state dict before the node is
58
+ dispatched; ``None`` means "always execute".
59
+ """
60
+
61
+ name: str
62
+ fn: NodeFn
63
+ max_attempts: int
64
+ backoff: tuple[float, ...]
65
+ when_fn: WhenFn | None = None
66
+
67
+
68
+ class WorkflowDefinition(BaseModel):
69
+ """Complete, validated description of a workflow.
70
+
71
+ Every decorator compiles to this, and the executor reads only from here —
72
+ so ``@pipeline`` and ``@scheduled`` differ by exactly one field
73
+ (``schedule``) rather than by an execution path.
74
+
75
+ Attributes:
76
+ name: Workflow identifier, unique per version.
77
+ version: Bumped when the step list changes shape.
78
+ state_cls: The TypedDict defining the state schema. ``None`` for
79
+ workflows that carry an untyped ``dict`` state.
80
+ nodes: The steps, **in execution order**.
81
+ reducers: ``field_name -> merge function``, extracted from the
82
+ ``Annotated`` hints on ``state_cls``. A field with no
83
+ reducer is last-write-wins.
84
+ schedule: Cron expression, or ``None`` for on-demand workflows.
85
+ """
86
+
87
+ name: str
88
+ version: int
89
+ state_cls: type | None
90
+ nodes: tuple[NodeDef, ...] = ()
91
+ reducers: dict[str, ReducerFn] = Field(default_factory=dict)
92
+ schedule: str | None = None
93
+
94
+ @model_validator(mode="after")
95
+ def _check_topology(self) -> WorkflowDefinition:
96
+ """Reject a definition that cannot run, at declaration time.
97
+
98
+ A model validator rather than a method a caller must remember to call:
99
+ the previous shape had a ``validate()`` each compiler had to invoke, and
100
+ one forgetting it produced a workflow that failed on its first dispatch
101
+ in production rather than at import.
102
+ """
103
+ if not self.nodes:
104
+ raise BadRequestError(f"workflow {self.name!r} v{self.version} has no steps")
105
+ seen: set[str] = set()
106
+ for node in self.nodes:
107
+ if node.name in seen:
108
+ raise BadRequestError(
109
+ f"workflow {self.name!r} v{self.version}: duplicate step name {node.name!r} — "
110
+ "step names identify rows in step_run and must be unique within a workflow"
111
+ )
112
+ seen.add(node.name)
113
+ return self
114
+
115
+ def node_names(self) -> tuple[str, ...]:
116
+ """Step names in execution order."""
117
+ return tuple(node.name for node in self.nodes)
118
+
119
+
120
+ __all__ = [
121
+ "NodeDef",
122
+ "NodeFn",
123
+ "WhenFn",
124
+ "WorkflowDefinition",
125
+ ]
@@ -0,0 +1,71 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """``RunFilter`` — what a caller is asking for, without any SQL.
25
+
26
+ Extracted so the read layer compiles a value object instead of reaching into a
27
+ query builder's private attributes, and so the same filter set can be applied to
28
+ the page query, the count and the summary. It previously could not be: each of
29
+ the three built its own ``where`` list, and the summary's list was missing the
30
+ metadata, state and current-node filters — so ``.metadata(k=v).summary()``
31
+ silently aggregated over every run in the table.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from datetime import datetime
37
+ from typing import Any
38
+
39
+ from forktex.types import BaseValueObject
40
+ from pydantic import Field
41
+
42
+
43
+ class RunFilter(BaseValueObject):
44
+ """The predicate half of a run query. Frozen, and free of I/O.
45
+
46
+ Every field is optional; an empty ``RunFilter`` matches every run.
47
+
48
+ Attributes:
49
+ workflow_name: Exact match on the workflow's name.
50
+ workflow_version: Exact match on its version. Only meaningful with a name.
51
+ statuses: Any-of match on run status.
52
+ metadata_filter: JSONB containment against ``run.metadata``.
53
+ state_filter: JSONB containment against ``run.input``.
54
+ current_nodes: Runs whose currently-running step is one of these.
55
+ since: ``started_at >= since``.
56
+ until: ``started_at < until``.
57
+ triggered_by: Any-of match on the trigger label.
58
+ """
59
+
60
+ workflow_name: str | None = None
61
+ workflow_version: int | None = None
62
+ statuses: tuple[str, ...] = ()
63
+ metadata_filter: dict[str, Any] = Field(default_factory=dict)
64
+ state_filter: dict[str, Any] = Field(default_factory=dict)
65
+ current_nodes: tuple[str, ...] = ()
66
+ since: datetime | None = None
67
+ until: datetime | None = None
68
+ triggered_by: tuple[str, ...] = ()
69
+
70
+
71
+ __all__ = ["RunFilter"]
@@ -0,0 +1,137 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of ForkTex Flow.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """The ``@step`` decorator and the spec it produces.
25
+
26
+ ``step`` is deliberately dual-mode. Bare ``@step`` attaches a retry contract to
27
+ the function and returns it unchanged, so a step is still an ordinary callable a
28
+ test can await directly. ``step(fn, when=...)`` instead returns a
29
+ :class:`StepSpec` for use inside a pipeline's ``steps`` list. The two forms
30
+ cannot be confused because the second requires ``fn`` positionally.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Callable
36
+ from typing import Any
37
+
38
+ from forktex.error import BadRequestError
39
+ from forktex.types import BaseValueObject
40
+
41
+ from forktex_flow.domain.config import RuntimeConfig
42
+
43
+ # Attribute the @step decorator attaches to a function to carry its retry
44
+ # contract. Named here once so call sites read it via ``step_meta`` rather
45
+ # than repeating the dunder string.
46
+ _STEP_META_ATTR = "__forktex_step_meta__"
47
+
48
+ #: The retry contract a bare `@step` gets. Read from `RuntimeConfig`'s own
49
+ #: defaults rather than restated here: the same two numbers written in three
50
+ #: places is how a consumer sets `RuntimeConfig(max_attempts=7)` and every step
51
+ #: still gets 3.
52
+ _DEFAULTS = RuntimeConfig()
53
+
54
+
55
+ class StepMeta(BaseValueObject):
56
+ """Retry + backoff configuration stored on the function itself.
57
+
58
+ The executor reads it via :func:`step_meta` at dispatch time. Storing it on
59
+ the function rather than in a registry means the function carries its own
60
+ durability contract and works with any ``Flow`` without prior registration.
61
+ """
62
+
63
+ max_attempts: int
64
+ backoff: tuple[float, ...]
65
+
66
+
67
+ class StepSpec(BaseValueObject):
68
+ """Returned by ``step(fn, when=...)`` for use in a pipeline's ``steps`` list."""
69
+
70
+ fn: Callable[..., Any]
71
+ when: Callable[[dict[str, Any]], bool] | None = None
72
+ max_attempts: int | None = None
73
+ backoff: tuple[float, ...] | None = None
74
+
75
+
76
+ def step_meta(fn: object) -> StepMeta | None:
77
+ """Return the :class:`StepMeta` attached by :func:`step`, or ``None``.
78
+
79
+ ``None`` is a legitimate result, not a broken invariant: the compilers
80
+ accept bare, undecorated callables and fall back to the default retry
81
+ config. The typed accessor centralises the attribute name and gives call
82
+ sites a precise return type without a per-site ``type: ignore``.
83
+ """
84
+ meta = getattr(fn, _STEP_META_ATTR, None)
85
+ return meta if isinstance(meta, StepMeta) else None
86
+
87
+
88
+ def has_step_meta(fn: object) -> bool:
89
+ """True if ``fn`` was decorated with :func:`step`."""
90
+ return isinstance(getattr(fn, _STEP_META_ATTR, None), StepMeta)
91
+
92
+
93
+ def step(
94
+ fn: Callable[..., Any] | None = None,
95
+ /,
96
+ *,
97
+ max_attempts: int | None = None,
98
+ backoff: tuple[float, ...] | None = None,
99
+ when: Callable[[dict[str, Any]], bool] | None = None,
100
+ ) -> Callable[..., Any] | StepSpec:
101
+ """Attach a retry contract to a step, or build a guarded pipeline entry.
102
+
103
+ As a decorator — ``@step`` or ``@step(max_attempts=5)`` — attaches
104
+ :class:`StepMeta` and returns ``fn`` unchanged.
105
+
106
+ As a pipeline entry — ``step(fn, when=predicate)`` — returns a
107
+ :class:`StepSpec`. ``fn`` must be positional.
108
+ """
109
+ resolved_max_attempts = max_attempts if max_attempts is not None else _DEFAULTS.max_attempts
110
+ resolved_backoff = backoff if backoff is not None else _DEFAULTS.backoff
111
+
112
+ def _attach_meta(target: Callable[..., Any]) -> Callable[..., Any]:
113
+ meta = StepMeta(max_attempts=resolved_max_attempts, backoff=resolved_backoff)
114
+ setattr(target, _STEP_META_ATTR, meta)
115
+ return target
116
+
117
+ if fn is None:
118
+ if when is not None:
119
+ # `when` selects a step *within a pipeline*, so it is meaningless
120
+ # without the function it guards. Raising here turns a silently
121
+ # ignored guard into an error at import time.
122
+ raise BadRequestError("step(when=...) requires fn as a positional argument")
123
+ return _attach_meta
124
+
125
+ if when is not None or max_attempts is not None or backoff is not None:
126
+ return StepSpec(fn=fn, when=when, max_attempts=max_attempts, backoff=backoff)
127
+
128
+ return _attach_meta(fn)
129
+
130
+
131
+ __all__ = [
132
+ "StepMeta",
133
+ "StepSpec",
134
+ "has_step_meta",
135
+ "step",
136
+ "step_meta",
137
+ ]