runfox 0.0.2__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.
runfox/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .backend.base import Backend
2
+ from .results import (Complete, Dispatch, DispatchJob, Halt, Pending,
3
+ StateChangeEvent)
4
+ from .status import StepStatus, WorkflowStatus
5
+ from .workflow import Workflow
runfox/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.2'
32
+ __version_tuple__ = version_tuple = (0, 0, 2)
33
+
34
+ __commit_id__ = commit_id = 'gf1470cbc4'
@@ -0,0 +1,9 @@
1
+ from .models import StepRecord, WorkflowRecord
2
+ from .base import Backend
3
+ from .inprocess_runner import InProcessRunner
4
+ from .inprocess_worker import InProcessWorker
5
+ from .runner import Runner
6
+ from .sqlite_runner import SqliteRunner
7
+ from .store import Store
8
+ from .inmemory_store import InMemoryStore
9
+ from .sqlite_store import SqliteStore
runfox/backend/base.py ADDED
@@ -0,0 +1,268 @@
1
+ """
2
+ base.py -- Backend
3
+
4
+ Composes a Store and a Runner. All workflow lifecycle operations are
5
+ implemented here in terms of self._store.load() and self._store.write().
6
+ dispatch() and gather() delegate to self._runner.
7
+
8
+ Construction
9
+ ------------
10
+ Backend(store=s, runner=r) -- explicit store and runner
11
+
12
+ Composite key accessors
13
+ -----------------------
14
+ workflow_execution_id(record)
15
+ step_key(wf_exec_id, step_id)
16
+ step_run_key(wf_exec_id, step_id, run_id)
17
+ """
18
+
19
+ import dataclasses
20
+ import datetime
21
+ import hashlib
22
+ import json
23
+ import random
24
+ import socket
25
+ import string
26
+ from typing import Any
27
+
28
+ from runfox.status import StepStatus, WorkflowStatus
29
+
30
+ from .models import StepRecord, WorkflowRecord
31
+ from .inprocess_runner import InProcessRunner
32
+ from .inprocess_worker import InProcessWorker
33
+ from .inmemory_store import InMemoryStore
34
+
35
+ class Backend:
36
+
37
+ def __init__(
38
+ self,
39
+ executor=None,
40
+ store=None,
41
+ runner=None,
42
+ poll_interval: float = 0.1,
43
+ on_state_change=None,
44
+ ):
45
+ """
46
+ on_state_change: optional callback fired after every state merge.
47
+ Signature: (workflow_execution_id, previous_state, new_state) -> None.
48
+ Must be pure: no side effects, no exceptions, no backend calls.
49
+ The callback fires inside a write cycle; any mutation of backend
50
+ state from within it will produce inconsistent records.
51
+ """
52
+ if store is None:
53
+ store = InMemoryStore()
54
+ if runner is None:
55
+ runner = InProcessRunner()
56
+ self._store = store
57
+ self._runner = runner
58
+ self._worker = InProcessWorker(runner, executor) if executor else None
59
+ self.poll_interval = poll_interval
60
+ self._on_state_change = on_state_change
61
+
62
+ # ------------------------------------------------------------------
63
+ # Private ID generation
64
+ # ------------------------------------------------------------------
65
+
66
+ def _make_workflow_id(self, spec: dict) -> str:
67
+ canonical = json.dumps(spec, sort_keys=True, separators=(",", ":"))
68
+ return hashlib.md5(canonical.encode()).hexdigest()
69
+
70
+ def _make_execution_id(self) -> str:
71
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S")
72
+ suffix = "".join(random.choices(string.hexdigits[:16], k=4)).lower()
73
+ return f"{ts}-{suffix}"
74
+
75
+ # ------------------------------------------------------------------
76
+ # Public composite key accessors
77
+ # ------------------------------------------------------------------
78
+
79
+ def workflow_execution_id(self, record: WorkflowRecord) -> str:
80
+ return f"{record.workflow_id}#{record.execution_id}"
81
+
82
+ def step_key(self, wf_exec_id: str, step_id: str) -> str:
83
+ return f"{wf_exec_id}#{step_id}"
84
+
85
+ def step_run_key(self, wf_exec_id: str, step_id: str, run_id: int) -> str:
86
+ return f"{wf_exec_id}#{step_id}#{run_id}"
87
+
88
+ # ------------------------------------------------------------------
89
+ # Utilities
90
+ # ------------------------------------------------------------------
91
+
92
+ def _now_iso(self) -> str:
93
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
94
+
95
+ def _make_step_record(self, step_id: str) -> StepRecord:
96
+ return StepRecord(id=step_id)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Store pass-throughs (used by Workflow and tests)
100
+ # ------------------------------------------------------------------
101
+
102
+ def load(self, workflow_execution_id: str) -> WorkflowRecord:
103
+ return self._store.load(workflow_execution_id)
104
+
105
+ def write(self, record: WorkflowRecord) -> None:
106
+ self._store.write(record)
107
+
108
+ # ------------------------------------------------------------------
109
+ # create
110
+ # ------------------------------------------------------------------
111
+
112
+ def create(self, spec: dict, inputs: dict = None) -> str:
113
+ record = WorkflowRecord(
114
+ workflow_id=self._make_workflow_id(spec),
115
+ execution_id=self._make_execution_id(),
116
+ spec=spec,
117
+ inputs=inputs or {},
118
+ state={},
119
+ steps={
120
+ step["id"]: self._make_step_record(step["id"]) for step in spec["steps"]
121
+ },
122
+ status=WorkflowStatus.PENDING,
123
+ )
124
+ self._store.write(record)
125
+ return self.workflow_execution_id(record)
126
+
127
+ # ------------------------------------------------------------------
128
+ # Named step operations
129
+ # ------------------------------------------------------------------
130
+
131
+ def mark_in_progress(self, workflow_execution_id: str, step_id: str) -> None:
132
+ record = self._store.load(workflow_execution_id)
133
+ new_step = dataclasses.replace(
134
+ record.steps[step_id],
135
+ status=StepStatus.IN_PROGRESS,
136
+ start_time=self._now_iso(),
137
+ host=socket.gethostname(),
138
+ )
139
+ self._store.write(
140
+ dataclasses.replace(
141
+ record,
142
+ status=WorkflowStatus.IN_PROGRESS,
143
+ steps={**record.steps, step_id: new_step},
144
+ )
145
+ )
146
+
147
+ def mark_complete(self, workflow_execution_id: str, step_id: str) -> None:
148
+ record = self._store.load(workflow_execution_id)
149
+ new_step = dataclasses.replace(
150
+ record.steps[step_id],
151
+ status=StepStatus.COMPLETE,
152
+ end_time=self._now_iso(),
153
+ )
154
+ self._store.write(
155
+ dataclasses.replace(
156
+ record,
157
+ steps={**record.steps, step_id: new_step},
158
+ )
159
+ )
160
+
161
+ def mark_halted(self, workflow_execution_id: str, step_id: str) -> None:
162
+ record = self._store.load(workflow_execution_id)
163
+ new_step = dataclasses.replace(
164
+ record.steps[step_id],
165
+ status=StepStatus.HALTED,
166
+ end_time=self._now_iso(),
167
+ )
168
+ self._store.write(
169
+ dataclasses.replace(
170
+ record,
171
+ status=WorkflowStatus.HALTED,
172
+ steps={**record.steps, step_id: new_step},
173
+ )
174
+ )
175
+
176
+ def write_step_output(
177
+ self, workflow_execution_id: str, step_id: str, output: dict
178
+ ) -> None:
179
+ record = self._store.load(workflow_execution_id)
180
+ new_step = dataclasses.replace(record.steps[step_id], output=output)
181
+ self._store.write(
182
+ dataclasses.replace(
183
+ record,
184
+ steps={**record.steps, step_id: new_step},
185
+ )
186
+ )
187
+
188
+ def merge_workflow_state(self, workflow_execution_id, output, event=None):
189
+ """
190
+ Update the internal workflow state with a new output.
191
+ Latest-wins update of keys.
192
+
193
+ on_state_change is called with
194
+ (workflow_execution_id, prev_state, new_state, event) if set.
195
+ event is a StateChangeEvent identifying the step that triggered
196
+ the merge, or None if called outside a step result context.
197
+ """
198
+ if not output:
199
+ return
200
+ record = self._store.load(workflow_execution_id)
201
+ new_state = {**record.state, **output}
202
+ if self._on_state_change:
203
+ # Callback must be pure. Side effects here are undefined behaviour --
204
+ # the store write has completed but the workflow has not advanced.
205
+ self._on_state_change(workflow_execution_id, record.state, new_state, event)
206
+ self._store.write(dataclasses.replace(record, state=new_state))
207
+
208
+ def write_workflow_outcome(self, workflow_execution_id: str, outcome: Any) -> None:
209
+ """send the result of the workflow to the store for writing"""
210
+ record = self._store.load(workflow_execution_id)
211
+ status = (
212
+ WorkflowStatus.HALTED
213
+ if record.status == WorkflowStatus.HALTED
214
+ else WorkflowStatus.COMPLETE
215
+ )
216
+ self._store.write(dataclasses.replace(record, outcome=outcome, status=status))
217
+
218
+ def reset_step(self, workflow_execution_id: str, step_id: str) -> None:
219
+ record = self._store.load(workflow_execution_id)
220
+ new_step = dataclasses.replace(
221
+ record.steps[step_id],
222
+ status=StepStatus.READY,
223
+ output=None,
224
+ start_time=None,
225
+ end_time=None,
226
+ run_id=record.steps[step_id].run_id + 1,
227
+ )
228
+ self._store.write(
229
+ dataclasses.replace(
230
+ record,
231
+ steps={**record.steps, step_id: new_step},
232
+ )
233
+ )
234
+
235
+ def reset_for_retry(self, workflow_execution_id: str, step_id: str) -> None:
236
+ record = self._store.load(workflow_execution_id)
237
+ new_step = dataclasses.replace(
238
+ record.steps[step_id],
239
+ status=StepStatus.RETRY,
240
+ run_id=record.steps[step_id].run_id + 1,
241
+ start_time=None,
242
+ end_time=None,
243
+ )
244
+ self._store.write(
245
+ dataclasses.replace(
246
+ record,
247
+ steps={**record.steps, step_id: new_step},
248
+ )
249
+ )
250
+
251
+ # ------------------------------------------------------------------
252
+ # Runner pass-throughs
253
+ # ------------------------------------------------------------------
254
+
255
+ def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
256
+ self._runner.dispatch(workflow_execution_id, jobs)
257
+
258
+ def gather(self, workflow_execution_id: str) -> list:
259
+ return self._runner.gather(workflow_execution_id)
260
+
261
+ def pending_tasks(self) -> list:
262
+ return self._runner.list_pending_jobs()
263
+
264
+ def take_tasks(self) -> list:
265
+ return self._runner.take_pending_jobs()
266
+
267
+ def submit_result(self, workflow_execution_id, step_id, output):
268
+ self._runner.submit_work_result(workflow_execution_id, step_id, output)
@@ -0,0 +1,36 @@
1
+ """
2
+ store.py -- Store
3
+
4
+ Two primitives:
5
+
6
+ load(workflow_execution_id) -> WorkflowRecord
7
+ write(record) -> None
8
+
9
+ Implementations: InMemoryStore, SqliteStore.
10
+ SqliteStore manages the workflows table only. The tasks table belongs to SqliteRunner.
11
+ """
12
+
13
+ import copy
14
+ import dataclasses
15
+ import json
16
+ import sqlite3
17
+
18
+ from runfox.status import StepStatus, WorkflowStatus
19
+
20
+ from .models import StepRecord, WorkflowRecord
21
+ from .store import Store
22
+
23
+
24
+ class InMemoryStore(Store):
25
+
26
+ def __init__(self):
27
+ self._store: dict[str, WorkflowRecord] = {}
28
+
29
+ def load(self, workflow_execution_id: str) -> WorkflowRecord:
30
+ if workflow_execution_id not in self._store:
31
+ raise KeyError(workflow_execution_id)
32
+ return copy.deepcopy(self._store[workflow_execution_id])
33
+
34
+ def write(self, record: WorkflowRecord) -> None:
35
+ key = f"{record.workflow_id}#{record.execution_id}"
36
+ self._store[key] = copy.deepcopy(record)
@@ -0,0 +1,74 @@
1
+ """
2
+ runner.py -- Runner
3
+
4
+ Two primitives:
5
+
6
+ dispatch(workflow_execution_id, jobs) -> None
7
+ gather(workflow_execution_id) -> list[tuple[str, dict]]
8
+
9
+ gather() always returns immediately. Returns an empty list if no results
10
+ are ready. The runner never calls on_step_result; Workflow.run() does that.
11
+
12
+ The runner is a job queue. dispatch() enqueues; gather() dequeues results.
13
+ The caller drives execution between those two calls -- a local function, a
14
+ thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
15
+ no runfox dependency regardless of which runner is used.
16
+
17
+ InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
18
+ the dict is the tasks table. Use InProcessWorker to drive
19
+ local execution against it.
20
+
21
+ SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
22
+ See worker protocol in class docstring.
23
+
24
+ InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
25
+ SqliteRunner worker protocol. The executor remains a plain
26
+ callable with no runfox dependency.
27
+ """
28
+
29
+ import datetime
30
+ import json
31
+ import sqlite3
32
+ from typing import Callable
33
+
34
+ from ..results import DispatchJob
35
+ from .runner import Runner
36
+
37
+
38
+ class InProcessRunner(Runner):
39
+ """
40
+ Dict-backed job queue.
41
+
42
+ Semantically identical to SqliteRunner: dispatch() writes to _pending
43
+ (equivalent to INSERT PENDING), gather() reads from _results (equivalent
44
+ to SELECT COMPLETE and mark PROCESSED). InProcessWorker drives execution
45
+ between those two calls, mirroring what an external worker does against
46
+ the SQLite tasks table.
47
+ """
48
+
49
+ def __init__(self):
50
+ self._pending: dict[str, list] = {}
51
+ self._results: dict[str, list] = {}
52
+
53
+ def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
54
+ existing = self._pending.get(workflow_execution_id, [])
55
+ self._pending[workflow_execution_id] = existing + list(jobs)
56
+
57
+ def gather(self, workflow_execution_id: str) -> list:
58
+ return self._results.pop(workflow_execution_id, [])
59
+
60
+ def list_pending_jobs(self) -> list:
61
+ """Non-destructive snapshot. Does not alter queue state."""
62
+ return [job for jobs in self._pending.values() for job in jobs]
63
+
64
+ def take_pending_jobs(self) -> list:
65
+ """Consume all pending jobs. Clears the queue."""
66
+ jobs = [job for jobs in self._pending.values() for job in jobs]
67
+ self._pending.clear()
68
+ return jobs
69
+
70
+ def submit_work_result(
71
+ self, workflow_execution_id: str, step_id: str, output: dict
72
+ ) -> None:
73
+ existing = self._results.get(workflow_execution_id, [])
74
+ self._results[workflow_execution_id] = existing + [(step_id, output)]
@@ -0,0 +1,68 @@
1
+ """
2
+ runner.py -- Runner
3
+
4
+ Two primitives:
5
+
6
+ dispatch(workflow_execution_id, jobs) -> None
7
+ gather(workflow_execution_id) -> list[tuple[str, dict]]
8
+
9
+ gather() always returns immediately. Returns an empty list if no results
10
+ are ready. The runner never calls on_step_result; Workflow.run() does that.
11
+
12
+ The runner is a job queue. dispatch() enqueues; gather() dequeues results.
13
+ The caller drives execution between those two calls -- a local function, a
14
+ thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
15
+ no runfox dependency regardless of which runner is used.
16
+
17
+ InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
18
+ the dict is the tasks table. Use InProcessWorker to drive
19
+ local execution against it.
20
+
21
+ SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
22
+ See worker protocol in class docstring.
23
+
24
+ InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
25
+ SqliteRunner worker protocol. The executor remains a plain
26
+ callable with no runfox dependency.
27
+ """
28
+
29
+ import datetime
30
+ import json
31
+ import sqlite3
32
+ from typing import Callable
33
+
34
+ from .inprocess_runner import InProcessRunner
35
+
36
+
37
+ class InProcessWorker:
38
+ """
39
+ Local worker harness for InProcessRunner.
40
+
41
+ Mirrors the SqliteRunner worker protocol using the runner's internal
42
+ dicts in place of the tasks table. The executor remains a plain callable
43
+ with no runfox dependency.
44
+
45
+ Equivalent remote pattern
46
+ -------------------------
47
+ This harness:
48
+ for job in runner.pending(wf_exec_id):
49
+ output = executor(job.fn, job.inputs)
50
+ runner.submit_work_result(wf_exec_id, job.step_id, output)
51
+
52
+ SQS/Lambda equivalent:
53
+ message = sqs.receive()
54
+ output = executor(message.fn, message.inputs)
55
+ dynamodb.put(task_key, output, status="COMPLETE")
56
+ """
57
+
58
+ def __init__(self, runner: InProcessRunner, executor: Callable[[str, dict], dict]):
59
+ self._runner = runner
60
+ self._executor = executor
61
+
62
+ def run(self, workflow_execution_id: str) -> None:
63
+ for job in self._runner.take_pending_jobs():
64
+ try:
65
+ output = self._executor(job.fn, job.inputs)
66
+ except Exception as exc:
67
+ output = {"error": str(exc), "ok": False}
68
+ self._runner.submit_work_result(workflow_execution_id, job.step_id, output)
@@ -0,0 +1,76 @@
1
+ """
2
+ base.py -- Backend
3
+
4
+ Composes a Store and a Runner. All workflow lifecycle operations are
5
+ implemented here in terms of self._store.load() and self._store.write().
6
+ dispatch() and gather() delegate to self._runner.
7
+
8
+ Construction
9
+ ------------
10
+ Backend(store=s, runner=r) -- explicit store and runner
11
+
12
+ Composite key accessors
13
+ -----------------------
14
+ workflow_execution_id(record)
15
+ step_key(wf_exec_id, step_id)
16
+ step_run_key(wf_exec_id, step_id, run_id)
17
+ """
18
+
19
+ import dataclasses
20
+ import datetime
21
+ import hashlib
22
+ import json
23
+ import random
24
+ import socket
25
+ import string
26
+ from typing import Any
27
+
28
+ from runfox.status import StepStatus, WorkflowStatus
29
+
30
+
31
+ @dataclasses.dataclass
32
+ class StepRecord:
33
+ """
34
+ Runtime state of a single step.
35
+
36
+ id -- step identifier
37
+ status -- current lifecycle status
38
+ output -- written by executor on completion; None until then
39
+ start_time -- ISO-8601 UTC; set when claimed
40
+ end_time -- ISO-8601 UTC; set on terminal status
41
+ host -- hostname of the claiming process
42
+ run_id -- incremented on every dispatch (retry or set-branch reset)
43
+ """
44
+
45
+ id: str
46
+ status: StepStatus = StepStatus.READY
47
+ output: dict | None = None
48
+ start_time: str | None = None
49
+ end_time: str | None = None
50
+ host: str | None = None
51
+ run_id: int = 0
52
+
53
+
54
+ @dataclasses.dataclass
55
+ class WorkflowRecord:
56
+ """
57
+ Runtime state of a workflow execution as returned by store.load().
58
+
59
+ workflow_id -- MD5 of canonical spec JSON
60
+ execution_id -- timestamp + short suffix; identifies one run
61
+ spec -- parsed workflow definition (immutable after create)
62
+ inputs -- workflow-level inputs (immutable after create)
63
+ state -- mutable shared accumulator
64
+ steps -- dict[step_id -> StepRecord]
65
+ status -- current workflow lifecycle status
66
+ outcome -- resolved outputs on completion, branch payload on halt
67
+ """
68
+
69
+ workflow_id: str
70
+ execution_id: str
71
+ spec: dict
72
+ inputs: dict
73
+ state: dict
74
+ steps: dict
75
+ status: WorkflowStatus
76
+ outcome: Any = None
@@ -0,0 +1,69 @@
1
+ """
2
+ runner.py -- Runner
3
+
4
+ Two primitives:
5
+
6
+ dispatch(workflow_execution_id, jobs) -> None
7
+ gather(workflow_execution_id) -> list[tuple[str, dict]]
8
+
9
+ gather() always returns immediately. Returns an empty list if no results
10
+ are ready. The runner never calls on_step_result; Workflow.run() does that.
11
+
12
+ The runner is a job queue. dispatch() enqueues; gather() dequeues results.
13
+ The caller drives execution between those two calls -- a local function, a
14
+ thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
15
+ no runfox dependency regardless of which runner is used.
16
+
17
+ InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
18
+ the dict is the tasks table. Use InProcessWorker to drive
19
+ local execution against it.
20
+
21
+ SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
22
+ See worker protocol in class docstring.
23
+
24
+ InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
25
+ SqliteRunner worker protocol. The executor remains a plain
26
+ callable with no runfox dependency.
27
+ """
28
+
29
+ import datetime
30
+ import json
31
+ import sqlite3
32
+ from typing import Callable
33
+
34
+ from ..results import DispatchJob
35
+
36
+
37
+ class Runner:
38
+
39
+ def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
40
+ """Enqueue jobs. jobs is a list of DispatchJob."""
41
+ raise NotImplementedError
42
+
43
+ def gather(self, workflow_execution_id: str) -> list:
44
+ """
45
+ Return completed (step_id, output) pairs. Always returns immediately.
46
+ Returns an empty list if no results are ready.
47
+ """
48
+ raise NotImplementedError
49
+
50
+ def list_pending_jobs(self) -> list:
51
+ """
52
+ Non-destructive snapshot of all pending jobs across all workflows.
53
+ Safe to call for diagnostics; does not affect queue state.
54
+ """
55
+ raise NotImplementedError
56
+
57
+ def take_pending_jobs(self) -> list:
58
+ """
59
+ Consume and return all pending jobs across all workflows.
60
+ Called by worker harnesses. Each returned job will not be
61
+ returned again by a subsequent call.
62
+ """
63
+ raise NotImplementedError
64
+
65
+ def submit_work_result(
66
+ self, workflow_execution_id: str, step_id: str, output: dict
67
+ ) -> None:
68
+ """Write a result back from a worker."""
69
+ raise NotImplementedError