funmill 0.1.1__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.
funmill/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ from .backends import (
2
+ BACKEND_SPECS,
3
+ BackendError,
4
+ TaskBackend,
5
+ get_backend,
6
+ list_backends,
7
+ )
8
+ from .models import (
9
+ CancelRequest,
10
+ RetryPolicy,
11
+ TaskAccepted,
12
+ TaskInfo,
13
+ TaskLanguage,
14
+ TaskLogs,
15
+ TaskProgress,
16
+ TaskResult,
17
+ TaskStatus,
18
+ TaskSubmit,
19
+ WorkflowSubmit,
20
+ WorkflowTask,
21
+ )
22
+
23
+ __all__ = [
24
+ "BACKEND_SPECS",
25
+ "BackendError",
26
+ "CancelRequest",
27
+ "RetryPolicy",
28
+ "TaskAccepted",
29
+ "TaskBackend",
30
+ "TaskInfo",
31
+ "TaskLanguage",
32
+ "TaskLogs",
33
+ "TaskProgress",
34
+ "TaskResult",
35
+ "TaskStatus",
36
+ "TaskSubmit",
37
+ "WorkflowSubmit",
38
+ "WorkflowTask",
39
+ "get_backend",
40
+ "list_backends",
41
+ ]
funmill/api.py ADDED
@@ -0,0 +1,102 @@
1
+ import os
2
+ import secrets
3
+ from contextlib import asynccontextmanager
4
+ from functools import lru_cache
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, FastAPI
8
+ from fastapi.responses import JSONResponse
9
+ from fastapi.security import APIKeyHeader
10
+
11
+ from funmill.backends import BackendError, TaskBackend, get_backend
12
+ from funmill.models import (
13
+ CancelRequest,
14
+ TaskAccepted,
15
+ TaskInfo,
16
+ TaskLogs,
17
+ TaskProgress,
18
+ TaskResult,
19
+ TaskSubmit,
20
+ WorkflowSubmit,
21
+ )
22
+
23
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
24
+
25
+
26
+ def require_api_key(api_key: Annotated[str | None, Depends(api_key_header)]) -> None:
27
+ expected = os.getenv("FUNMILL_API_KEY", "")
28
+ if not expected:
29
+ raise BackendError("FUNMILL_API_KEY is not configured", 503)
30
+ if api_key is None or not secrets.compare_digest(api_key, expected):
31
+ raise BackendError("invalid API key", 401)
32
+
33
+
34
+ @lru_cache
35
+ def backend_dependency() -> TaskBackend:
36
+ return get_backend()
37
+
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(_: FastAPI):
41
+ yield
42
+ if backend_dependency.cache_info().currsize:
43
+ backend_dependency().close()
44
+ backend_dependency.cache_clear()
45
+
46
+
47
+ app = FastAPI(title="Funmill", version="0.1.0", lifespan=lifespan)
48
+ router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
49
+ Backend = Annotated[TaskBackend, Depends(backend_dependency)]
50
+
51
+
52
+ @app.exception_handler(BackendError)
53
+ async def backend_error_handler(_, exc: BackendError):
54
+ return JSONResponse(status_code=exc.status_code, content={"detail": str(exc)})
55
+
56
+
57
+ @app.get("/health")
58
+ def health():
59
+ return {"status": "ok", "backend": os.getenv("FUNMILL_BACKEND", "windmill")}
60
+
61
+
62
+ @router.post("/tasks", response_model=TaskAccepted, status_code=202)
63
+ def submit_task(task: TaskSubmit, backend: Backend):
64
+ return TaskAccepted(task_id=backend.submit_task(task))
65
+
66
+
67
+ @router.post("/workflows", response_model=TaskAccepted, status_code=202)
68
+ def submit_workflow(workflow: WorkflowSubmit, backend: Backend):
69
+ return TaskAccepted(task_id=backend.submit_workflow(workflow))
70
+
71
+
72
+ @router.get("/tasks/{task_id}", response_model=TaskInfo)
73
+ def get_task(task_id: str, backend: Backend):
74
+ return backend.get_task(task_id)
75
+
76
+
77
+ @router.get("/tasks/{task_id}/progress", response_model=TaskProgress)
78
+ def get_progress(task_id: str, backend: Backend):
79
+ return backend.get_progress(task_id)
80
+
81
+
82
+ @router.get("/tasks/{task_id}/logs", response_model=TaskLogs)
83
+ def get_logs(task_id: str, backend: Backend):
84
+ return backend.get_logs(task_id)
85
+
86
+
87
+ @router.get("/tasks/{task_id}/result", response_model=TaskResult)
88
+ def get_result(task_id: str, backend: Backend):
89
+ return backend.get_result(task_id)
90
+
91
+
92
+ @router.post("/tasks/{task_id}/cancel", status_code=204)
93
+ def cancel(task_id: str, request: CancelRequest, backend: Backend) -> None:
94
+ backend.cancel(task_id, request.reason)
95
+
96
+
97
+ @router.post("/tasks/{task_id}/rerun", response_model=TaskAccepted, status_code=202)
98
+ def rerun(task_id: str, backend: Backend):
99
+ return TaskAccepted(task_id=backend.rerun(task_id), rerun_of=task_id)
100
+
101
+
102
+ app.include_router(router)
@@ -0,0 +1,41 @@
1
+ import importlib
2
+ import os
3
+ from typing import Any, NamedTuple
4
+
5
+ from .base import BackendError, TaskBackend
6
+
7
+
8
+ class BackendSpec(NamedTuple):
9
+ module: str
10
+ cls: str
11
+ service: str | None = None
12
+
13
+
14
+ BACKEND_SPECS = {
15
+ "windmill": BackendSpec(".windmill", "WindmillBackend", ".windmill.service"),
16
+ }
17
+
18
+
19
+ def get_backend(backend_type: str | None = None, **kwargs: Any) -> TaskBackend:
20
+ key = (backend_type or os.getenv("FUNMILL_BACKEND", "windmill")).lower()
21
+ spec = BACKEND_SPECS.get(key)
22
+ if spec is None:
23
+ raise ValueError(
24
+ f"unsupported backend {key!r}; available: {', '.join(BACKEND_SPECS)}"
25
+ )
26
+ module = importlib.import_module(spec.module, __name__)
27
+ backend_class = getattr(module, spec.cls)
28
+ return backend_class(**kwargs) if kwargs else backend_class.from_env()
29
+
30
+
31
+ def list_backends() -> list[str]:
32
+ return list(BACKEND_SPECS)
33
+
34
+
35
+ __all__ = [
36
+ "BACKEND_SPECS",
37
+ "BackendError",
38
+ "TaskBackend",
39
+ "get_backend",
40
+ "list_backends",
41
+ ]
@@ -0,0 +1,47 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from funmill.models import (
4
+ TaskInfo,
5
+ TaskLogs,
6
+ TaskProgress,
7
+ TaskResult,
8
+ TaskSubmit,
9
+ WorkflowSubmit,
10
+ )
11
+
12
+
13
+ class BackendError(RuntimeError):
14
+ def __init__(self, message: str, status_code: int = 502) -> None:
15
+ super().__init__(message)
16
+ self.status_code = status_code
17
+
18
+
19
+ class TaskBackend(ABC):
20
+ name: str
21
+
22
+ @abstractmethod
23
+ def submit_task(self, task: TaskSubmit) -> str: ...
24
+
25
+ @abstractmethod
26
+ def submit_workflow(self, workflow: WorkflowSubmit) -> str: ...
27
+
28
+ @abstractmethod
29
+ def get_task(self, task_id: str) -> TaskInfo: ...
30
+
31
+ @abstractmethod
32
+ def get_progress(self, task_id: str) -> TaskProgress: ...
33
+
34
+ @abstractmethod
35
+ def get_logs(self, task_id: str) -> TaskLogs: ...
36
+
37
+ @abstractmethod
38
+ def get_result(self, task_id: str) -> TaskResult: ...
39
+
40
+ @abstractmethod
41
+ def cancel(self, task_id: str, reason: str) -> None: ...
42
+
43
+ @abstractmethod
44
+ def rerun(self, task_id: str) -> str: ...
45
+
46
+ @abstractmethod
47
+ def close(self) -> None: ...
@@ -0,0 +1,87 @@
1
+ # Windmill 简单部署
2
+
3
+ 以下方式不使用 Docker,适用于 Linux x86_64。需要一套可用的 PostgreSQL,
4
+ 以及 `python3`、`uv` 和 Bash;Windmill 会自动初始化数据库表。
5
+
6
+ ## 1. 安装 Funmill 和 Windmill
7
+
8
+ ```bash
9
+ uv sync
10
+ uv run funmill install windmill
11
+ ```
12
+
13
+ Windmill 会安装到 `~/.farfarfun/funmill/services/windmill/`。安装器会校验
14
+ 官方发行包的 SHA-256;设置 `FUNMILL_HOME` 可以修改安装目录。
15
+
16
+ ## 2. 准备数据库
17
+
18
+ 在 PostgreSQL 中创建用户和数据库:
19
+
20
+ ```bash
21
+ sudo -u postgres createuser --pwprompt windmill
22
+ sudo -u postgres createdb --owner=windmill windmill
23
+ ```
24
+
25
+ 然后确认下面的连接可以使用:
26
+
27
+ ```text
28
+ postgres://windmill:数据库密码@127.0.0.1:5432/windmill
29
+ ```
30
+
31
+ ## 3. 启动 Windmill
32
+
33
+ `standalone` 模式会在一个进程中同时运行 Server 和一个 Worker:
34
+
35
+ ```bash
36
+ DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
37
+ MODE=standalone \
38
+ PORT=8001 \
39
+ BASE_URL='http://127.0.0.1:8001' \
40
+ SERVER_BIND_ADDR=127.0.0.1 \
41
+ uv run funmill start windmill
42
+ ```
43
+
44
+ 打开 <http://127.0.0.1:8001>,首次登录使用:
45
+
46
+ ```text
47
+ admin@windmill.dev / changeme
48
+ ```
49
+
50
+ 立即修改密码,并在 `admins` workspace 中创建 API Token。
51
+
52
+ ## 4. 启动 Funmill API
53
+
54
+ 回到 Funmill 仓库根目录执行:
55
+
56
+ ```bash
57
+ FUNMILL_API_KEY='自行设置的接口密钥' \
58
+ FUNMILL_BACKEND=windmill \
59
+ WINDMILL_URL='http://127.0.0.1:8001' \
60
+ WINDMILL_WORKSPACE=admins \
61
+ WINDMILL_TOKEN='刚创建的Windmill-Token' \
62
+ uv run funmill start
63
+ ```
64
+
65
+ 验证:
66
+
67
+ ```bash
68
+ curl http://127.0.0.1:8000/health
69
+ FUNMILL_API_KEY='自行设置的接口密钥' ./scripts/smoke.sh
70
+ ```
71
+
72
+ ## 增加 Worker
73
+
74
+ 需要更高并发时,在同一台或其他机器额外启动 Worker。每个进程使用不同的
75
+ `WORKER_SUFFIX`:
76
+
77
+ ```bash
78
+ DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
79
+ MODE=worker WORKER_SUFFIX=worker2 uv run funmill start windmill
80
+
81
+ DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
82
+ MODE=worker WORKER_SUFFIX=worker3 uv run funmill start windmill
83
+ ```
84
+
85
+ 正式长期运行时,将上述命令交给现有的 systemd 或进程管理器即可。不要在
86
+ 一个普通 Worker 进程中设置 `NUM_WORKERS>1`;Windmill 会因为隔离安全限制将
87
+ 它回退为 1,多个独立 Worker 进程更明确。
@@ -0,0 +1,393 @@
1
+ import json
2
+ import os
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ import httpx
7
+
8
+ from funmill.models import (
9
+ TaskDefinition,
10
+ TaskInfo,
11
+ TaskLanguage,
12
+ TaskLogs,
13
+ TaskProgress,
14
+ TaskResult,
15
+ TaskStatus,
16
+ TaskSubmit,
17
+ WorkflowSubmit,
18
+ )
19
+
20
+ from ..base import BackendError, TaskBackend
21
+
22
+ _CALLBACK_SOURCE = """import json
23
+ import os
24
+ from urllib.request import Request, urlopen
25
+
26
+ def main(callback_url: str, status: str, payload):
27
+ body = {
28
+ "task_id": os.environ["WM_ROOT_JOB_ID"],
29
+ "status": status,
30
+ "payload": payload,
31
+ }
32
+ request = Request(
33
+ callback_url,
34
+ data=json.dumps(body, default=str).encode(),
35
+ headers={"Content-Type": "application/json"},
36
+ method="POST",
37
+ )
38
+ with urlopen(request, timeout=10):
39
+ return payload
40
+ """
41
+
42
+ _DEPENDENCY_SOURCE = """from datetime import datetime, timezone
43
+ import os
44
+ from wmill import get_job
45
+
46
+ def main(dependencies: list[str], timeout_seconds: int):
47
+ root = get_job(os.environ["WM_ROOT_JOB_ID"])
48
+ created_at = datetime.fromisoformat(root["created_at"].replace("Z", "+00:00"))
49
+ if (datetime.now(timezone.utc) - created_at).total_seconds() > timeout_seconds:
50
+ raise TimeoutError("dependency wait timed out")
51
+
52
+ for task_id in dependencies:
53
+ job = get_job(task_id)
54
+ if "success" not in job:
55
+ return False
56
+ if job.get("canceled"):
57
+ raise RuntimeError(f"dependency {task_id} was canceled")
58
+ if not job["success"]:
59
+ raise RuntimeError(f"dependency {task_id} failed")
60
+ return True
61
+ """
62
+
63
+ _RESULT_SOURCE = """def main(results):
64
+ return results
65
+ """
66
+
67
+ _LANGUAGES = {
68
+ TaskLanguage.PYTHON: "python3",
69
+ TaskLanguage.BASH: "bash",
70
+ }
71
+
72
+
73
+ class WindmillBackend(TaskBackend):
74
+ name = "windmill"
75
+
76
+ def __init__(
77
+ self,
78
+ base_url: str,
79
+ workspace: str,
80
+ token: str,
81
+ timeout: float = 30,
82
+ client: httpx.Client | None = None,
83
+ ) -> None:
84
+ self.token = token
85
+ self.client = client or httpx.Client(
86
+ base_url=f"{base_url.rstrip('/')}/api/w/{workspace}/",
87
+ timeout=timeout,
88
+ )
89
+
90
+ @classmethod
91
+ def from_env(cls) -> "WindmillBackend":
92
+ return cls(
93
+ base_url=os.getenv("WINDMILL_URL", "http://windmill_server:8000"),
94
+ workspace=os.getenv("WINDMILL_WORKSPACE", "admins"),
95
+ token=os.getenv("WINDMILL_TOKEN", ""),
96
+ timeout=float(os.getenv("WINDMILL_TIMEOUT", "30")),
97
+ )
98
+
99
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
100
+ if not self.token:
101
+ raise BackendError("WINDMILL_TOKEN is not configured", 503)
102
+ try:
103
+ response = self.client.request(
104
+ method,
105
+ path,
106
+ headers={"Authorization": f"Bearer {self.token}"},
107
+ **kwargs,
108
+ )
109
+ except httpx.TimeoutException as exc:
110
+ raise BackendError("Windmill request timed out", 504) from exc
111
+ except httpx.HTTPError as exc:
112
+ raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
113
+ if response.is_error:
114
+ status_code = 404 if response.status_code == 404 else 502
115
+ detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
116
+ raise BackendError(f"Windmill rejected the request: {detail}", status_code)
117
+ return response
118
+
119
+ def _submit_flow(self, value: dict[str, Any], args: dict[str, Any]) -> str:
120
+ response = self._request(
121
+ "POST", "jobs/run/preview_flow", json={"value": value, "args": args}
122
+ )
123
+ return self._response_task_id(response)
124
+
125
+ @staticmethod
126
+ def _response_task_id(response: httpx.Response) -> str:
127
+ lines = response.text.strip().splitlines()
128
+ if not lines:
129
+ raise BackendError("Windmill returned an empty task ID")
130
+ try:
131
+ return str(UUID(lines[0]))
132
+ except ValueError as exc:
133
+ raise BackendError(
134
+ f"Windmill returned an invalid task ID: {lines[0]!r}"
135
+ ) from exc
136
+
137
+ @staticmethod
138
+ def _task_module(task_id: str, task: TaskDefinition) -> dict[str, Any]:
139
+ module: dict[str, Any] = {
140
+ "id": task_id,
141
+ "value": {
142
+ "type": "rawscript",
143
+ "language": _LANGUAGES[task.language],
144
+ "content": task.source,
145
+ "input_transforms": {
146
+ key: {"type": "static", "value": value}
147
+ for key, value in task.args.items()
148
+ },
149
+ },
150
+ }
151
+ if task.retry.attempts:
152
+ module["retry"] = {
153
+ "constant": {
154
+ "attempts": task.retry.attempts,
155
+ "seconds": task.retry.delay_seconds,
156
+ }
157
+ }
158
+ if task.timeout_seconds:
159
+ module["timeout"] = {"type": "static", "value": task.timeout_seconds}
160
+ return module
161
+
162
+ @staticmethod
163
+ def _dependency_module(
164
+ dependencies: list[str], timeout_seconds: int
165
+ ) -> dict[str, Any]:
166
+ # ponytail: polling creates one short Windmill job per interval; replace with
167
+ # backend completion events when dependency volume makes that measurable.
168
+ return {
169
+ "id": "funmill_wait",
170
+ "value": {
171
+ "type": "whileloopflow",
172
+ "skip_failures": False,
173
+ "modules": [
174
+ {
175
+ "id": "funmill_check_dependencies",
176
+ "value": {
177
+ "type": "rawscript",
178
+ "language": "python3",
179
+ "content": _DEPENDENCY_SOURCE,
180
+ "input_transforms": {
181
+ "dependencies": {
182
+ "type": "static",
183
+ "value": dependencies,
184
+ },
185
+ "timeout_seconds": {
186
+ "type": "static",
187
+ "value": timeout_seconds,
188
+ },
189
+ },
190
+ },
191
+ "sleep": {"type": "static", "value": 2},
192
+ "stop_after_if": {"expr": "result === true"},
193
+ }
194
+ ],
195
+ },
196
+ }
197
+
198
+ @staticmethod
199
+ def _add_callback(
200
+ value: dict[str, Any], callback_url: str | None, result_id: str
201
+ ) -> None:
202
+ if callback_url is None:
203
+ return
204
+
205
+ def callback_module(module_id: str, status: str, payload_expr: str):
206
+ return {
207
+ "id": module_id,
208
+ "value": {
209
+ "type": "rawscript",
210
+ "language": "python3",
211
+ "content": _CALLBACK_SOURCE,
212
+ "input_transforms": {
213
+ "callback_url": {"type": "static", "value": callback_url},
214
+ "status": {"type": "static", "value": status},
215
+ "payload": {"type": "javascript", "expr": payload_expr},
216
+ },
217
+ },
218
+ "retry": {"constant": {"attempts": 3, "seconds": 2}},
219
+ }
220
+
221
+ result_expr = f"results[{json.dumps(result_id)}]"
222
+ value["modules"].append(
223
+ callback_module("funmill_callback", "succeeded", result_expr)
224
+ )
225
+ value["failure_module"] = callback_module("failure", "failed", "error")
226
+
227
+ @staticmethod
228
+ def _result_module(task_results: dict[str, str]) -> dict[str, Any]:
229
+ entries = ", ".join(
230
+ f"{json.dumps(task_id)}: {result_expr}"
231
+ for task_id, result_expr in task_results.items()
232
+ )
233
+ return {
234
+ "id": "funmill_result",
235
+ "value": {
236
+ "type": "rawscript",
237
+ "language": "python3",
238
+ "content": _RESULT_SOURCE,
239
+ "input_transforms": {
240
+ "results": {
241
+ "type": "javascript",
242
+ "expr": f"({{{entries}}})",
243
+ }
244
+ },
245
+ },
246
+ }
247
+
248
+ def submit_task(self, task: TaskSubmit) -> str:
249
+ modules = []
250
+ if task.depends_on:
251
+ modules.append(
252
+ self._dependency_module(
253
+ task.depends_on, task.dependency_timeout_seconds
254
+ )
255
+ )
256
+ modules.append(self._task_module("task", task))
257
+ value = {"modules": modules}
258
+ self._add_callback(
259
+ value,
260
+ str(task.callback_url) if task.callback_url else None,
261
+ "task",
262
+ )
263
+ return self._submit_flow(value, {})
264
+
265
+ def submit_workflow(self, workflow: WorkflowSubmit) -> str:
266
+ modules = []
267
+ task_results = {}
268
+ if workflow.depends_on:
269
+ modules.append(
270
+ self._dependency_module(
271
+ workflow.depends_on, workflow.dependency_timeout_seconds
272
+ )
273
+ )
274
+
275
+ for index, layer in enumerate(workflow.topological_layers()):
276
+ if len(layer) == 1:
277
+ module = self._task_module(layer[0].key, layer[0])
278
+ task_results[layer[0].key] = f"results[{json.dumps(layer[0].key)}]"
279
+ else:
280
+ layer_id = f"funmill_layer_{index}"
281
+ module = {
282
+ "id": layer_id,
283
+ "value": {
284
+ "type": "branchall",
285
+ "parallel": True,
286
+ "branches": [
287
+ {
288
+ "summary": task.key,
289
+ "modules": [self._task_module(task.key, task)],
290
+ }
291
+ for task in layer
292
+ ],
293
+ },
294
+ }
295
+ for task_index, task in enumerate(layer):
296
+ task_results[task.key] = (
297
+ f"results[{json.dumps(layer_id)}][{task_index}]"
298
+ )
299
+ modules.append(module)
300
+
301
+ result_module = self._result_module(task_results)
302
+ modules.append(result_module)
303
+
304
+ value = {"modules": modules}
305
+ self._add_callback(
306
+ value,
307
+ str(workflow.callback_url) if workflow.callback_url else None,
308
+ result_module["id"],
309
+ )
310
+ return self._submit_flow(value, {})
311
+
312
+ def _get_job(self, task_id: str) -> dict[str, Any]:
313
+ return self._request("GET", f"jobs_u/get/{task_id}").json()
314
+
315
+ @staticmethod
316
+ def _status(job: dict[str, Any]) -> TaskStatus:
317
+ if job.get("canceled"):
318
+ return TaskStatus.CANCELED
319
+ if "success" in job:
320
+ return TaskStatus.SUCCEEDED if job["success"] else TaskStatus.FAILED
321
+ return TaskStatus.RUNNING if job.get("running") else TaskStatus.QUEUED
322
+
323
+ def get_task(self, task_id: str) -> TaskInfo:
324
+ job = self._get_job(task_id)
325
+ return TaskInfo(
326
+ task_id=task_id,
327
+ status=self._status(job),
328
+ created_at=job.get("created_at"),
329
+ started_at=job.get("started_at"),
330
+ completed_at=job.get("completed_at"),
331
+ duration_ms=job.get("duration_ms"),
332
+ )
333
+
334
+ def get_progress(self, task_id: str) -> TaskProgress:
335
+ job = self._get_job(task_id)
336
+ status = self._status(job)
337
+ if status == TaskStatus.SUCCEEDED:
338
+ return TaskProgress(task_id=task_id, progress=100)
339
+
340
+ modules = (job.get("flow_status") or {}).get("modules") or []
341
+ scores = []
342
+ for module in modules:
343
+ if module.get("type") == "Success":
344
+ scores.append(100)
345
+ elif module.get("type") == "InProgress":
346
+ scores.append(module.get("progress") or 0)
347
+ else:
348
+ scores.append(0)
349
+ progress = int(sum(scores) / len(scores)) if scores else None
350
+ return TaskProgress(task_id=task_id, progress=progress)
351
+
352
+ def get_logs(self, task_id: str) -> TaskLogs:
353
+ job = self._get_job(task_id)
354
+ if job.get("job_kind") in {"flow", "flowpreview", "singlestepflow"}:
355
+ response = self._request("GET", f"jobs_u/get_flow_all_logs/{task_id}")
356
+ logs = response.text
357
+ else:
358
+ logs = self._request("GET", f"jobs_u/get_logs/{task_id}").text
359
+ return TaskLogs(task_id=task_id, logs=logs)
360
+
361
+ def get_result(self, task_id: str) -> TaskResult:
362
+ result = self._request("GET", f"jobs_u/completed/get_result/{task_id}").json()
363
+ return TaskResult(task_id=task_id, result=result)
364
+
365
+ def cancel(self, task_id: str, reason: str) -> None:
366
+ self._request("POST", f"jobs_u/queue/cancel/{task_id}", json={"reason": reason})
367
+
368
+ def rerun(self, task_id: str) -> str:
369
+ job = self._get_job(task_id)
370
+ raw_flow = job.get("raw_flow")
371
+ if raw_flow:
372
+ return self._submit_flow(raw_flow, job.get("args") or {})
373
+
374
+ raw_code = job.get("raw_code")
375
+ if raw_code:
376
+ response = self._request(
377
+ "POST",
378
+ "jobs/run/preview",
379
+ json={
380
+ "content": raw_code,
381
+ "language": job.get("language"),
382
+ "kind": "code",
383
+ "args": job.get("args") or {},
384
+ },
385
+ )
386
+ return self._response_task_id(response)
387
+
388
+ raise BackendError(
389
+ "task cannot be rerun because its source is unavailable", 409
390
+ )
391
+
392
+ def close(self) -> None:
393
+ self.client.close()
@@ -0,0 +1,76 @@
1
+ import hashlib
2
+ import os
3
+ import platform
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+ from urllib.request import Request, urlopen
8
+
9
+ VERSION = "v1.808.0"
10
+ URL = f"https://github.com/windmill-labs/windmill/releases/download/{VERSION}/windmill-amd64"
11
+ SHA256 = "ed48bfb9a391daa437f0c867376f009c7186855530de7fe2f58cf557ff1f7c3a"
12
+
13
+
14
+ def _target() -> Path:
15
+ home = Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
16
+ return home / "services" / "windmill" / "windmill"
17
+
18
+
19
+ def _sha256(path: Path) -> str:
20
+ with path.open("rb") as file:
21
+ return hashlib.file_digest(file, "sha256").hexdigest()
22
+
23
+
24
+ def install(force: bool = False) -> Path:
25
+ if platform.system() != "Linux" or platform.machine().lower() not in {
26
+ "x86_64",
27
+ "amd64",
28
+ }:
29
+ raise RuntimeError("Windmill v1.808.0 installer only supports Linux x86_64")
30
+
31
+ target = _target()
32
+ if target.exists() and _sha256(target) == SHA256:
33
+ return target
34
+ if target.exists() and not force:
35
+ raise RuntimeError(
36
+ f"{target} exists but has an unexpected checksum; use --force"
37
+ )
38
+
39
+ target.parent.mkdir(parents=True, exist_ok=True)
40
+ descriptor, temporary_name = tempfile.mkstemp(dir=target.parent)
41
+ os.close(descriptor)
42
+ temporary = Path(temporary_name)
43
+ try:
44
+ print(f"downloading Windmill {VERSION}...", flush=True)
45
+ request = Request(URL, headers={"User-Agent": "funmill"})
46
+ with urlopen(request, timeout=30) as response, temporary.open("wb") as file:
47
+ shutil.copyfileobj(response, file)
48
+ if _sha256(temporary) != SHA256:
49
+ raise RuntimeError("downloaded Windmill binary failed SHA-256 verification")
50
+ temporary.chmod(0o755)
51
+ temporary.replace(target)
52
+ finally:
53
+ temporary.unlink(missing_ok=True)
54
+ return target
55
+
56
+
57
+ def start() -> None:
58
+ executable = _target()
59
+ if not executable.exists():
60
+ system_executable = shutil.which("windmill")
61
+ if system_executable is None:
62
+ raise RuntimeError(
63
+ "Windmill is not installed; run: funmill install windmill"
64
+ )
65
+ executable = Path(system_executable)
66
+
67
+ if not os.getenv("DATABASE_URL"):
68
+ raise RuntimeError("DATABASE_URL is required")
69
+
70
+ environment = os.environ.copy()
71
+ mode = environment.setdefault("MODE", "standalone")
72
+ if mode in {"standalone", "server"}:
73
+ environment.setdefault("PORT", "8001")
74
+ environment.setdefault("BASE_URL", "http://127.0.0.1:8001")
75
+ environment.setdefault("SERVER_BIND_ADDR", "127.0.0.1")
76
+ os.execve(executable, [str(executable)], environment)
funmill/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ import argparse
2
+ import importlib
3
+ import os
4
+ from collections.abc import Sequence
5
+ from types import ModuleType
6
+
7
+ import uvicorn
8
+
9
+ from funmill.backends import BACKEND_SPECS
10
+
11
+
12
+ def _service_names() -> list[str]:
13
+ return [name for name, spec in BACKEND_SPECS.items() if spec.service]
14
+
15
+
16
+ def _service(name: str) -> ModuleType:
17
+ spec = BACKEND_SPECS[name]
18
+ return importlib.import_module(spec.service, "funmill.backends")
19
+
20
+
21
+ def _parser() -> argparse.ArgumentParser:
22
+ parser = argparse.ArgumentParser(prog="funmill")
23
+ commands = parser.add_subparsers(dest="command")
24
+
25
+ commands.add_parser("services", help="列出可安装的第三方服务")
26
+
27
+ install = commands.add_parser("install", help="安装第三方服务")
28
+ install.add_argument("service", choices=_service_names())
29
+ install.add_argument("--force", action="store_true", help="覆盖现有安装")
30
+
31
+ start = commands.add_parser("start", help="启动 Funmill 或第三方服务")
32
+ start.add_argument(
33
+ "service", nargs="?", choices=["api", *_service_names()], default="api"
34
+ )
35
+ return parser
36
+
37
+
38
+ def main(argv: Sequence[str] | None = None) -> None:
39
+ parser = _parser()
40
+ args = parser.parse_args(argv)
41
+
42
+ try:
43
+ if args.command == "services":
44
+ print("\n".join(_service_names()))
45
+ elif args.command == "install":
46
+ path = _service(args.service).install(force=args.force)
47
+ print(f"installed {args.service}: {path}")
48
+ elif args.command == "start" and args.service != "api":
49
+ _service(args.service).start()
50
+ elif args.command == "start":
51
+ uvicorn.run(
52
+ "funmill.api:app",
53
+ host=os.getenv("FUNMILL_HOST", "127.0.0.1"),
54
+ port=int(os.getenv("FUNMILL_PORT", "8000")),
55
+ )
56
+ else:
57
+ parser.print_help()
58
+ except (OSError, RuntimeError, ValueError) as exc:
59
+ parser.exit(1, f"error: {exc}\n")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
funmill/models.py ADDED
@@ -0,0 +1,120 @@
1
+ from datetime import datetime
2
+ from enum import StrEnum
3
+ from typing import Annotated, Any, Self
4
+
5
+ from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
6
+
7
+ DependencyId = Annotated[str, Field(min_length=1, max_length=200)]
8
+
9
+
10
+ class TaskLanguage(StrEnum):
11
+ PYTHON = "python"
12
+ BASH = "bash"
13
+
14
+
15
+ class TaskStatus(StrEnum):
16
+ QUEUED = "queued"
17
+ RUNNING = "running"
18
+ SUCCEEDED = "succeeded"
19
+ FAILED = "failed"
20
+ CANCELED = "canceled"
21
+
22
+
23
+ class RetryPolicy(BaseModel):
24
+ attempts: int = Field(default=0, ge=0, le=10)
25
+ delay_seconds: int = Field(default=1, ge=0, le=3600)
26
+
27
+
28
+ class TaskDefinition(BaseModel):
29
+ language: TaskLanguage
30
+ source: str = Field(min_length=1, max_length=1_000_000)
31
+ args: dict[str, Any] = Field(default_factory=dict)
32
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
33
+ timeout_seconds: int | None = Field(default=None, ge=1, le=86_400)
34
+
35
+
36
+ class TaskSubmit(TaskDefinition):
37
+ depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
38
+ dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
39
+ callback_url: AnyHttpUrl | None = None
40
+
41
+
42
+ class WorkflowTask(TaskDefinition):
43
+ key: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
44
+ depends_on: list[str] = Field(default_factory=list, max_length=100)
45
+
46
+
47
+ class WorkflowSubmit(BaseModel):
48
+ tasks: list[WorkflowTask] = Field(min_length=1, max_length=100)
49
+ depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
50
+ dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
51
+ callback_url: AnyHttpUrl | None = None
52
+
53
+ @model_validator(mode="after")
54
+ def validate_dependencies(self) -> Self:
55
+ keys = [task.key for task in self.tasks]
56
+ if len(keys) != len(set(keys)):
57
+ raise ValueError("workflow task keys must be unique")
58
+ if any(key == "failure" or key.startswith("funmill_") for key in keys):
59
+ raise ValueError("task keys cannot be 'failure' or start with 'funmill_'")
60
+
61
+ known = set(keys)
62
+ for task in self.tasks:
63
+ unknown = set(task.depends_on) - known
64
+ if unknown:
65
+ raise ValueError(
66
+ f"task {task.key!r} has unknown dependencies: {sorted(unknown)}"
67
+ )
68
+ self.topological_layers()
69
+ return self
70
+
71
+ def topological_layers(self) -> list[list[WorkflowTask]]:
72
+ by_key = {task.key: task for task in self.tasks}
73
+ remaining = {task.key: set(task.depends_on) for task in self.tasks}
74
+ layers: list[list[WorkflowTask]] = []
75
+ while remaining:
76
+ ready = [key for key, dependencies in remaining.items() if not dependencies]
77
+ if not ready:
78
+ raise ValueError("workflow contains a dependency cycle")
79
+ layers.append([by_key[key] for key in ready])
80
+ ready_set = set(ready)
81
+ remaining = {
82
+ key: dependencies - ready_set
83
+ for key, dependencies in remaining.items()
84
+ if key not in ready_set
85
+ }
86
+ return layers
87
+
88
+
89
+ class TaskAccepted(BaseModel):
90
+ task_id: str
91
+ status: TaskStatus = TaskStatus.QUEUED
92
+ rerun_of: str | None = None
93
+
94
+
95
+ class TaskInfo(BaseModel):
96
+ task_id: str
97
+ status: TaskStatus
98
+ created_at: datetime | None = None
99
+ started_at: datetime | None = None
100
+ completed_at: datetime | None = None
101
+ duration_ms: int | None = None
102
+
103
+
104
+ class TaskProgress(BaseModel):
105
+ task_id: str
106
+ progress: int | None
107
+
108
+
109
+ class TaskLogs(BaseModel):
110
+ task_id: str
111
+ logs: str
112
+
113
+
114
+ class TaskResult(BaseModel):
115
+ task_id: str
116
+ result: Any
117
+
118
+
119
+ class CancelRequest(BaseModel):
120
+ reason: str = Field(default="canceled through Funmill", max_length=500)
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: funmill
3
+ Version: 0.1.1
4
+ Summary: Backend-neutral task execution API
5
+ Project-URL: Organization, https://github.com/farfarfun
6
+ Project-URL: Repository, https://github.com/farfarfun/funmill
7
+ Project-URL: Releases, https://github.com/farfarfun/funmill/releases
8
+ Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
9
+ Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
10
+ License-Expression: MIT
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: fastapi<1,>=0.115
13
+ Requires-Dist: httpx<1,>=0.27
14
+ Requires-Dist: uvicorn<1,>=0.30
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Funmill
18
+
19
+ Funmill provides one stable task API while execution is delegated to a
20
+ replaceable backend. The first backend is self-hosted
21
+ [Windmill](https://github.com/windmill-labs/windmill), pinned to `v1.808.0`.
22
+
23
+ ```text
24
+ client -> Funmill /v1 -> TaskBackend -> Windmill
25
+ -> another backend later
26
+ ```
27
+
28
+ Funmill owns the public request and response models. Backend job IDs remain
29
+ opaque strings, and no Windmill routes or payloads are exposed to clients.
30
+
31
+ ## Start
32
+
33
+ Install the project and the Windmill binary:
34
+
35
+ ```bash
36
+ uv sync
37
+ uv run funmill install windmill
38
+ ```
39
+
40
+ Start Windmill against an existing PostgreSQL database:
41
+
42
+ ```bash
43
+ DATABASE_URL='postgres://windmill:password@127.0.0.1:5432/windmill' \
44
+ uv run funmill start windmill
45
+ ```
46
+
47
+ Open <http://localhost:8001>, log in with `admin@windmill.dev` / `changeme`,
48
+ change the password, and create an API token in the `admins` workspace. Then
49
+ start Funmill in another terminal:
50
+
51
+ ```bash
52
+ FUNMILL_API_KEY='replace-me' \
53
+ WINDMILL_URL='http://127.0.0.1:8001' \
54
+ WINDMILL_WORKSPACE=admins \
55
+ WINDMILL_TOKEN='replace-me' \
56
+ uv run funmill start
57
+ ```
58
+
59
+ The API is at <http://localhost:8000>; OpenAPI docs are at
60
+ <http://localhost:8000/docs>. All `/v1` routes require `X-API-Key`. See the
61
+ [Windmill deployment guide](src/funmill/backends/windmill/README.md) for
62
+ PostgreSQL setup and additional workers.
63
+
64
+ Run the end-to-end task and DAG checks with:
65
+
66
+ ```bash
67
+ FUNMILL_API_KEY=the-value-from-env ./scripts/smoke.sh
68
+ ```
69
+
70
+ Set `FUNMILL_CALLBACK_URL` to test callbacks. The URL must be reachable from
71
+ the Windmill workers.
72
+
73
+ ## API
74
+
75
+ | Operation | Route |
76
+ | --- | --- |
77
+ | Submit Python/Bash | `POST /v1/tasks` |
78
+ | Submit a DAG | `POST /v1/workflows` |
79
+ | Status | `GET /v1/tasks/{task_id}` |
80
+ | Logs | `GET /v1/tasks/{task_id}/logs` |
81
+ | Progress | `GET /v1/tasks/{task_id}/progress` |
82
+ | Result | `GET /v1/tasks/{task_id}/result` |
83
+ | Cancel | `POST /v1/tasks/{task_id}/cancel` |
84
+ | Rerun | `POST /v1/tasks/{task_id}/rerun` |
85
+
86
+ Submit one task, optionally waiting for existing task IDs:
87
+
88
+ ```json
89
+ {
90
+ "language": "python",
91
+ "source": "def main(value: int):\n return value * 2\n",
92
+ "args": {"value": 21},
93
+ "depends_on": ["EXISTING_TASK_ID"],
94
+ "dependency_timeout_seconds": 3600,
95
+ "retry": {"attempts": 2, "delay_seconds": 5},
96
+ "timeout_seconds": 300,
97
+ "callback_url": "https://example.internal/task-callback"
98
+ }
99
+ ```
100
+
101
+ Submit `A -> [B, C]` as one workflow:
102
+
103
+ ```json
104
+ {
105
+ "tasks": [
106
+ {"key": "a", "language": "python", "source": "def main(): return 1"},
107
+ {"key": "b", "language": "python", "source": "def main(): return 2", "depends_on": ["a"]},
108
+ {"key": "c", "language": "bash", "source": "main() { echo 3; }", "depends_on": ["a"]}
109
+ ]
110
+ }
111
+ ```
112
+
113
+ Dependencies inside a workflow are task keys. Top-level `depends_on` values are
114
+ IDs returned by earlier Funmill submissions. The Windmill backend checks those
115
+ dependencies in short jobs separated by Flow sleeps, so waiting does not hold a
116
+ worker process. Failure or cancellation of a dependency fails the waiting task.
117
+ Workflow results and callback payloads are objects keyed by every workflow task
118
+ key. Each topological layer is a synchronization barrier; tasks in the same
119
+ layer run in parallel.
120
+
121
+ Callbacks contain `task_id`, `status`, and `payload`; success and failure
122
+ delivery retry three times. Delivery is at least once, so callback receivers
123
+ must be idempotent.
124
+
125
+ ## Backends
126
+
127
+ The public contract is `TaskBackend` in `src/funmill/backends/base.py`. Backend
128
+ selection uses `FUNMILL_BACKEND`; registration lives in
129
+ `src/funmill/backends/__init__.py`, following the same driver pattern as
130
+ `fundrive`. Each third-party adapter lives in its own directory, such as
131
+ `src/funmill/backends/windmill/`.
132
+
133
+ Changing the backend does not change `/v1`, but it does not migrate old jobs or
134
+ their IDs. Add a Funmill-owned ID mapping database only when jobs must remain
135
+ queryable after a live backend migration.
136
+
137
+ ## Operations
138
+
139
+ ```bash
140
+ funmill services
141
+ funmill install windmill
142
+ funmill start windmill
143
+ funmill start
144
+ ```
145
+
146
+ Both start commands run in the foreground and stop normally with `Ctrl+C`.
147
+ Use systemd or an existing process manager for long-running deployment. Add
148
+ TLS, PostgreSQL backups, callback egress restrictions, and a secrets manager
149
+ before network exposure.
@@ -0,0 +1,13 @@
1
+ funmill/__init__.py,sha256=8nPJr4q1cpkaKWSURBmqUYnzF1ZFtnP1e3dZUT6OloA,687
2
+ funmill/api.py,sha256=y9B6Q6SH0P1ysBaIkwlUjVvFZBfo2t8vvBA7Ajz80Oo,3094
3
+ funmill/cli.py,sha256=nhjz6IOA9yJqViP-eoW6zukJaBsBz4uwV8DUwt8IWHU,1980
4
+ funmill/models.py,sha256=c0R-gJi2Tfo8TZKDz-Gwmd5Qd4fGmrNE_n5AjV2jmzM,3756
5
+ funmill/backends/__init__.py,sha256=0bpElxJzkCbD13WqujfR_ojG-hGVLMaxvauJ2WvXy5Y,1000
6
+ funmill/backends/base.py,sha256=2_dv5BXEzq8h5WV0lEtk7ccbjzvsrEDnFA4tcJuajEA,1066
7
+ funmill/backends/windmill/README.md,sha256=IXAHqgYwbb_Al7i_Y7mmimtVkTr76MNavIudQg5LofQ,2334
8
+ funmill/backends/windmill/__init__.py,sha256=-0K_z4FRTOxl-gNlBpU3zUIhbzyqMVUcuqUh9SNwZsI,13758
9
+ funmill/backends/windmill/service.py,sha256=hYPC3ZMKLKyqliPVEACUMRLqmeybuij5-luviLVfbpw,2647
10
+ funmill-0.1.1.dist-info/METADATA,sha256=XEkJuyrw4nyyHvxXLGkfQOPTvenPKLQwm8mpqNodHRg,4881
11
+ funmill-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ funmill-0.1.1.dist-info/entry_points.txt,sha256=EIJo5giXw8ZTlQcpPjbzEiG9A1qaLOd4eiBXHE6fYPE,45
13
+ funmill-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ funmill = funmill.cli:main