reflex-workflow 0.0.1a1__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,59 @@
1
+ """Durable workflows as SQLAlchemy tables on Postgres."""
2
+
3
+ from reflex_workflow.cron import Cron
4
+ from reflex_workflow.engine import (
5
+ OnIdle,
6
+ RunHandle,
7
+ connect_workflows,
8
+ run_workflows,
9
+ wake,
10
+ )
11
+ from reflex_workflow.model import (
12
+ DEFAULT_LANE,
13
+ AttemptLog,
14
+ Call,
15
+ Child,
16
+ Every,
17
+ FanOut,
18
+ Limit,
19
+ RateBucket,
20
+ Schedule,
21
+ Step,
22
+ Wait,
23
+ WakeIn,
24
+ Workflow,
25
+ child,
26
+ every,
27
+ fan_out,
28
+ step,
29
+ wait_for,
30
+ wake_in,
31
+ )
32
+
33
+ __all__ = [
34
+ "DEFAULT_LANE",
35
+ "AttemptLog",
36
+ "Call",
37
+ "Child",
38
+ "Cron",
39
+ "Every",
40
+ "FanOut",
41
+ "Limit",
42
+ "OnIdle",
43
+ "RateBucket",
44
+ "RunHandle",
45
+ "Schedule",
46
+ "Step",
47
+ "Wait",
48
+ "WakeIn",
49
+ "Workflow",
50
+ "child",
51
+ "connect_workflows",
52
+ "every",
53
+ "fan_out",
54
+ "run_workflows",
55
+ "step",
56
+ "wait_for",
57
+ "wake",
58
+ "wake_in",
59
+ ]
@@ -0,0 +1,209 @@
1
+ """Standard five-field cron expressions and the times they fire."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import zoneinfo
7
+
8
+ # Each field's bounds, in the order the fields are written.
9
+ BOUNDS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
10
+
11
+ # fmt: off
12
+ MONTHS = ["jan", "feb", "mar", "apr", "may", "jun",
13
+ "jul", "aug", "sep", "oct", "nov", "dec"]
14
+ DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
15
+ # fmt: on
16
+
17
+ # Days to look ahead before calling an expression unsatisfiable; the sparsest
18
+ # standard expression, February 29th, comes round at most every eight years.
19
+ SEARCH_DAYS = 366 * 9
20
+
21
+
22
+ def parse_name(value: str, field: int) -> str:
23
+ """Turn a month or weekday name into its number.
24
+
25
+ Args:
26
+ value: One term of a field, which may be a name.
27
+ field: Which field it belongs to, counting from the minute.
28
+
29
+ Returns:
30
+ The value with any name replaced by its number.
31
+ """
32
+ names = MONTHS if field == 3 else DAYS if field == 4 else ()
33
+ lowered = value.lower()
34
+ return (
35
+ str(names.index(lowered) + (1 if field == 3 else 0))
36
+ if lowered in names
37
+ else value
38
+ )
39
+
40
+
41
+ def parse_field(field: str, index: int) -> frozenset[int]:
42
+ """Turn one cron field into the set of values it matches.
43
+
44
+ Args:
45
+ field: The field, e.g. ``*/5`` or ``MON-FRI``.
46
+ index: Which field it is, counting from the minute.
47
+
48
+ Returns:
49
+ The values it matches.
50
+
51
+ Raises:
52
+ ValueError: If the field is not a valid cron field.
53
+ """
54
+ low, high = BOUNDS[index]
55
+ matched: set[int] = set()
56
+ for term in field.split(","):
57
+ part, slash, step_text = term.partition("/")
58
+ if slash and not step_text:
59
+ msg = f"{term!r} has a slash but no step."
60
+ raise ValueError(msg)
61
+ try:
62
+ step = int(step_text) if step_text else 1
63
+ except ValueError:
64
+ msg = f"{term!r} has a step that is not a number."
65
+ raise ValueError(msg) from None
66
+ if step < 1:
67
+ msg = f"{term!r} has a step below one."
68
+ raise ValueError(msg)
69
+ if part == "*":
70
+ first, last = low, high
71
+ else:
72
+ start, dash, end = part.partition("-")
73
+ try:
74
+ first = int(parse_name(start, index))
75
+ last = (
76
+ int(parse_name(end, index))
77
+ if dash
78
+ else (high if step_text else first)
79
+ )
80
+ # A range that ends on Sunday by name, like FRI-SUN, ends on 7;
81
+ # one that starts there too, SUN-SUN, is Sunday alone.
82
+ if index == 4 and dash and end.lower() == "sun" and first:
83
+ last = 7
84
+ except ValueError:
85
+ msg = f"{term!r} is not a number, a name, or a range."
86
+ raise ValueError(msg) from None
87
+ if not (low <= first <= high and low <= last <= high):
88
+ msg = f"{term!r} is outside {low}-{high}."
89
+ raise ValueError(msg)
90
+ if first > last:
91
+ msg = f"{term!r} counts down; write it as two terms."
92
+ raise ValueError(msg)
93
+ values = range(first, last + 1, step)
94
+ # Sunday is written as either 0 or 7, so a range like FRI-SUN stays
95
+ # ascending and folds only once it is expanded.
96
+ if index == 4:
97
+ matched.update(value % 7 for value in values)
98
+ else:
99
+ matched.update(values)
100
+ return frozenset(matched)
101
+
102
+
103
+ class Cron:
104
+ """A cron expression, and the times it fires in one timezone.
105
+
106
+ Supports the five standard fields -- minute, hour, day of month, month, day of
107
+ week -- with ``*``, lists, ranges, steps, and month and weekday names. Calling
108
+ it returns the next time it fires after a given moment, so it can be passed to
109
+ ``every`` wherever a schedule is wanted.
110
+ """
111
+
112
+ def __init__(
113
+ self, expression: str, tz: str | datetime.tzinfo = datetime.UTC
114
+ ) -> None:
115
+ """Parse an expression, rejecting one that is malformed or never fires.
116
+
117
+ Args:
118
+ expression: A five-field cron expression, e.g. ``0 9 * * MON-FRI``.
119
+ tz: The timezone its times are written in.
120
+
121
+ Raises:
122
+ ValueError: If the expression is malformed or can never fire.
123
+ """
124
+ fields = expression.split()
125
+ if len(fields) != 5:
126
+ msg = (
127
+ f"{expression!r} must have five fields: minute, hour, day of month, "
128
+ "month, and day of week."
129
+ )
130
+ raise ValueError(msg)
131
+ self.expression = expression
132
+ self.tz = zoneinfo.ZoneInfo(tz) if isinstance(tz, str) else tz
133
+ self.minutes, self.hours, self.days, self.months, self.weekdays = tuple(
134
+ parse_field(field, index) for index, field in enumerate(fields)
135
+ )
136
+ # Vixie cron matches both day fields when either is written as a star,
137
+ # and either field when both name particular days.
138
+ self.both_days = fields[2].startswith("*") or fields[4].startswith("*")
139
+ try:
140
+ self(datetime.datetime.now(datetime.UTC))
141
+ except ValueError:
142
+ msg = f"{expression!r} never fires."
143
+ raise ValueError(msg) from None
144
+
145
+ def __repr__(self) -> str:
146
+ """Name the expression in reprs and error messages.
147
+
148
+ Returns:
149
+ The expression's repr.
150
+ """
151
+ return f"Cron({self.expression!r}, {self.tz!s})"
152
+
153
+ def matches_day(self, day: datetime.date) -> bool:
154
+ """Report whether the expression fires on a date.
155
+
156
+ Args:
157
+ day: The date.
158
+
159
+ Returns:
160
+ Whether the day's fields match.
161
+ """
162
+ if day.month not in self.months:
163
+ return False
164
+ # isoweekday is Monday 1 through Sunday 7; cron counts Sunday as 0.
165
+ by_day, by_weekday = day.day in self.days, day.isoweekday() % 7 in self.weekdays
166
+ return (by_day and by_weekday) if self.both_days else (by_day or by_weekday)
167
+
168
+ def __call__(self, after: datetime.datetime) -> datetime.datetime:
169
+ """Return the first time the expression fires after a moment.
170
+
171
+ Times are matched on the clock in this expression's timezone. A time that
172
+ the clock skips over in spring fires at the equivalent moment after the
173
+ jump, and one the clock repeats in autumn fires on its first pass only, so
174
+ a schedule fires once either way.
175
+
176
+ Args:
177
+ after: The moment to search from.
178
+
179
+ Returns:
180
+ The next time it fires, always later than ``after``.
181
+
182
+ Raises:
183
+ ValueError: If ``after`` has no timezone, or the expression does not
184
+ fire within the next nine years.
185
+ """
186
+ if after.tzinfo is None:
187
+ msg = "Cron needs a timezone-aware moment to search from."
188
+ raise ValueError(msg)
189
+ local = after.astimezone(self.tz)
190
+ cursor = local.replace(second=0, microsecond=0) + datetime.timedelta(minutes=1)
191
+ hours, minutes = sorted(self.hours), sorted(self.minutes)
192
+ for _ in range(SEARCH_DAYS):
193
+ if self.matches_day(cursor.date()):
194
+ for hour in hours:
195
+ if hour < cursor.hour:
196
+ continue
197
+ for minute in minutes:
198
+ if hour == cursor.hour and minute < cursor.minute:
199
+ continue
200
+ fires = cursor.replace(hour=hour, minute=minute).astimezone(
201
+ datetime.UTC
202
+ )
203
+ # A repeated hour can put a later clock time at an earlier
204
+ # moment; those have already fired.
205
+ if fires > after:
206
+ return fires
207
+ cursor = (cursor + datetime.timedelta(days=1)).replace(hour=0, minute=0)
208
+ msg = f"{self.expression!r} does not fire within {SEARCH_DAYS // 366} years."
209
+ raise ValueError(msg)
@@ -0,0 +1,134 @@
1
+ """The same mixins, for applications whose models are dataclasses.
2
+
3
+ An application that maps its tables on a ``MappedAsDataclass`` base cannot mix
4
+ in a class that is not one itself: SQLAlchemy warns about it in 2.0 and refuses
5
+ it in 2.1. The mixins here are that same class declared as a dataclass, so::
6
+
7
+ from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
8
+ from reflex_workflow.dataclass import Workflow
9
+
10
+
11
+ class Base(MappedAsDataclass, DeclarativeBase, kw_only=True):
12
+ pass
13
+
14
+
15
+ class Onboarding(Base, Workflow):
16
+ __tablename__ = "onboarding"
17
+
18
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
19
+ email: Mapped[str]
20
+
21
+ Everything else is unchanged: the same columns, the same steps, the same
22
+ engine. The columns the engine owns are ``init=False``, so they stay out of the
23
+ constructor and ``Onboarding(email=...)`` reads as it did before the mixin was
24
+ added.
25
+
26
+ Which import to take is decided by the base, not by preference. On a plain
27
+ ``DeclarativeBase`` these would make the model a dataclass, which is a change
28
+ the application did not ask for, so that base takes ``reflex_workflow`` itself.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime
34
+ from typing import Any, ClassVar
35
+
36
+ from sqlalchemy import DateTime, Float, Index, Integer, String, Text
37
+ from sqlalchemy.dialects.postgresql import JSONB
38
+ from sqlalchemy.orm import Mapped, MappedAsDataclass, declared_attr, mapped_column
39
+
40
+ from reflex_workflow import model
41
+ from reflex_workflow.model import ZERO
42
+
43
+ __all__ = ["AttemptLog", "RateBucket", "Workflow"]
44
+
45
+
46
+ class Workflow(MappedAsDataclass, model.Workflow):
47
+ """``reflex_workflow.Workflow``, for a model that is a dataclass.
48
+
49
+ Each row is one run. The engine's own columns are ``init=False``: they are
50
+ the engine's to set, and a run is started with ``start`` rather than by
51
+ passing them.
52
+ """
53
+
54
+ next_step: Mapped[str | None] = mapped_column(
55
+ String(128), default=None, index=True, init=False
56
+ )
57
+ next_args: Mapped[dict[str, Any] | None] = mapped_column(
58
+ JSONB(none_as_null=True), default=None, init=False
59
+ )
60
+ wake_at: Mapped[datetime.datetime | None] = mapped_column(
61
+ DateTime(timezone=True), default=None, index=True, init=False
62
+ )
63
+ attempts: Mapped[int] = mapped_column(
64
+ Integer, default=0, server_default=ZERO, init=False
65
+ )
66
+ last_error: Mapped[str | None] = mapped_column(Text, default=None, init=False)
67
+ claimed_until: Mapped[datetime.datetime | None] = mapped_column(
68
+ DateTime(timezone=True), default=None, init=False
69
+ )
70
+ waiting_for: Mapped[str | None] = mapped_column(
71
+ String(128), default=None, index=True, init=False
72
+ )
73
+ parent: Mapped[dict[str, Any] | None] = mapped_column(
74
+ JSONB(none_as_null=True), default=None, init=False
75
+ )
76
+ children_left: Mapped[int | None] = mapped_column(Integer, default=None, init=False)
77
+ pending_event: Mapped[dict[str, Any] | None] = mapped_column(
78
+ JSONB(none_as_null=True), default=None, init=False
79
+ )
80
+ recent_event_keys: Mapped[list[str] | None] = mapped_column(
81
+ JSONB(none_as_null=True), default=None, init=False
82
+ )
83
+ wf_version: Mapped[int] = mapped_column(
84
+ Integer, default=0, server_default=ZERO, init=False
85
+ )
86
+
87
+
88
+ class AttemptLog(MappedAsDataclass, model.AttemptLog):
89
+ """``reflex_workflow.AttemptLog``, for a model that is a dataclass.
90
+
91
+ Every column is ``init=False``: the engine writes these rows with an insert
92
+ of its own, and nothing constructs one.
93
+ """
94
+
95
+ __tablename__: ClassVar[str]
96
+
97
+ id: Mapped[int] = mapped_column(
98
+ Integer, primary_key=True, autoincrement=True, init=False
99
+ )
100
+ workflow: Mapped[str] = mapped_column(String(128), init=False)
101
+ run: Mapped[list[Any]] = mapped_column(JSONB, init=False)
102
+ step: Mapped[str] = mapped_column(String(128), init=False)
103
+ attempt: Mapped[int] = mapped_column(Integer, init=False)
104
+ outcome: Mapped[str] = mapped_column(String(16), init=False)
105
+ error: Mapped[str | None] = mapped_column(Text, default=None, init=False)
106
+ took_ms: Mapped[int] = mapped_column(Integer, init=False)
107
+ finished_at: Mapped[datetime.datetime] = mapped_column(
108
+ DateTime(timezone=True), init=False
109
+ )
110
+
111
+ @declared_attr.directive
112
+ def __table_args__(cls) -> tuple[Any, ...]: # noqa: N805
113
+ """Index the history by the run it belongs to.
114
+
115
+ Returns:
116
+ The table's arguments.
117
+ """
118
+ return (Index(f"ix_{cls.__tablename__}_run", "workflow", "run"),)
119
+
120
+
121
+ class RateBucket(MappedAsDataclass, model.RateBucket):
122
+ """``reflex_workflow.RateBucket``, for a model that is a dataclass.
123
+
124
+ Every column is ``init=False``: the engine keeps these rows itself, with an
125
+ upsert that creates a group's bucket the first time it is seen.
126
+ """
127
+
128
+ key: Mapped[str] = mapped_column(String(256), primary_key=True, init=False)
129
+ tokens: Mapped[float] = mapped_column(
130
+ Float, default=0.0, server_default=ZERO, init=False
131
+ )
132
+ updated_at: Mapped[datetime.datetime] = mapped_column(
133
+ DateTime(timezone=True), init=False
134
+ )
@@ -0,0 +1,13 @@
1
+ """Execution of workflow steps: scheduling runs, claiming due rows, and running them."""
2
+
3
+ from reflex_workflow.engine.handles import RunHandle, start
4
+ from reflex_workflow.engine.runner import OnIdle, connect_workflows, run_workflows, wake
5
+
6
+ __all__ = [
7
+ "OnIdle",
8
+ "RunHandle",
9
+ "connect_workflows",
10
+ "run_workflows",
11
+ "start",
12
+ "wake",
13
+ ]