funmill-api 0.1.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.
@@ -0,0 +1,151 @@
1
+ import hashlib
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from urllib.request import Request, urlopen
9
+
10
+ from funmill.api.ports import SERVICE_BIND_HOST, THIRD_PARTY_WEB_PORT
11
+
12
+ from ..service import start_background, status_background, stop_background
13
+
14
+ VERSION = "v1.808.0"
15
+ URL = f"https://github.com/windmill-labs/windmill/releases/download/{VERSION}/windmill-amd64"
16
+ SHA256 = "ed48bfb9a391daa437f0c867376f009c7186855530de7fe2f58cf557ff1f7c3a"
17
+ _ENV_TEMPLATE = f"""DATABASE_URL=
18
+ MODE=standalone
19
+ SERVER_BIND_ADDR={SERVICE_BIND_HOST}
20
+ """
21
+
22
+
23
+ def _home() -> Path:
24
+ return Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
25
+
26
+
27
+ def _target() -> Path:
28
+ return _home() / "services" / "windmill" / "windmill"
29
+
30
+
31
+ def _config_path() -> Path:
32
+ return _target().parent / ".env"
33
+
34
+
35
+ def _ensure_config() -> Path:
36
+ path = _config_path()
37
+ if not path.exists():
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text(_ENV_TEMPLATE, encoding="utf-8")
40
+ path.chmod(0o600)
41
+ print(f"created config: {path}")
42
+ return path
43
+
44
+
45
+ def _read_config(path: Path) -> dict[str, str]:
46
+ values = {}
47
+ for line_number, raw_line in enumerate(
48
+ path.read_text(encoding="utf-8").splitlines(), 1
49
+ ):
50
+ line = raw_line.strip()
51
+ if not line or line.startswith("#"):
52
+ continue
53
+ key, separator, value = line.partition("=")
54
+ key = key.strip()
55
+ if not separator or not key.isidentifier():
56
+ raise RuntimeError(f"invalid config at {path}:{line_number}")
57
+ value = value.strip()
58
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
59
+ value = value[1:-1]
60
+ values[key] = value
61
+ return values
62
+
63
+
64
+ def _sha256(path: Path) -> str:
65
+ with path.open("rb") as file:
66
+ return hashlib.file_digest(file, "sha256").hexdigest()
67
+
68
+
69
+ def _environment() -> dict[str, str]:
70
+ path = _config_path()
71
+ environment = _read_config(path) if path.exists() else {}
72
+ environment.update(os.environ)
73
+ return environment
74
+
75
+
76
+ def _instance_name(environment: dict[str, str]) -> str:
77
+ if environment.get("MODE", "standalone") != "worker":
78
+ return "windmill"
79
+ suffix = environment.get("WORKER_SUFFIX", "worker")
80
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", suffix):
81
+ raise RuntimeError("WORKER_SUFFIX must contain only letters, numbers, _ or -")
82
+ return f"windmill-{suffix}"
83
+
84
+
85
+ def install(force: bool = False) -> Path:
86
+ if platform.system() != "Linux" or platform.machine().lower() not in {
87
+ "x86_64",
88
+ "amd64",
89
+ }:
90
+ raise RuntimeError("Windmill v1.808.0 installer only supports Linux x86_64")
91
+
92
+ target = _target()
93
+ _ensure_config()
94
+ if target.exists() and _sha256(target) == SHA256:
95
+ return target
96
+ if target.exists() and not force:
97
+ raise RuntimeError(
98
+ f"{target} exists but has an unexpected checksum; use --force"
99
+ )
100
+
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ descriptor, temporary_name = tempfile.mkstemp(dir=target.parent)
103
+ os.close(descriptor)
104
+ temporary = Path(temporary_name)
105
+ try:
106
+ print(f"downloading Windmill {VERSION}...", flush=True)
107
+ request = Request(URL, headers={"User-Agent": "funmill"})
108
+ with urlopen(request, timeout=30) as response, temporary.open("wb") as file:
109
+ shutil.copyfileobj(response, file)
110
+ if _sha256(temporary) != SHA256:
111
+ raise RuntimeError("downloaded Windmill binary failed SHA-256 verification")
112
+ temporary.chmod(0o755)
113
+ temporary.replace(target)
114
+ finally:
115
+ temporary.unlink(missing_ok=True)
116
+ return target
117
+
118
+
119
+ def start() -> None:
120
+ executable = _target()
121
+ if not executable.exists():
122
+ system_executable = shutil.which("windmill")
123
+ if system_executable is None:
124
+ raise RuntimeError(
125
+ "Windmill is not installed; run: funmill install windmill"
126
+ )
127
+ executable = Path(system_executable)
128
+
129
+ config = _ensure_config()
130
+ environment = _environment()
131
+ if not environment.get("DATABASE_URL"):
132
+ raise RuntimeError(f"DATABASE_URL is required; edit {config}")
133
+
134
+ mode = environment.setdefault("MODE", "standalone")
135
+ if mode in {"standalone", "server"}:
136
+ environment["PORT"] = str(THIRD_PARTY_WEB_PORT)
137
+ environment.setdefault("BASE_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}")
138
+ environment["SERVER_BIND_ADDR"] = SERVICE_BIND_HOST
139
+ start_background(
140
+ _instance_name(environment), [str(executable)], environment, _target().parent
141
+ )
142
+
143
+
144
+ def stop() -> None:
145
+ environment = _environment()
146
+ stop_background(_instance_name(environment), _target().parent)
147
+
148
+
149
+ def status() -> bool:
150
+ environment = _environment()
151
+ return status_background(_instance_name(environment), _target().parent)
funmill/api/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ import argparse
2
+ import importlib
3
+ from collections.abc import Sequence
4
+ from types import ModuleType
5
+
6
+ import uvicorn
7
+
8
+ from funmill.api.backends import BACKEND_SPECS
9
+ from funmill.api.ports import FUNMILL_API_PORT, SERVICE_BIND_HOST
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.api.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
+
36
+ for command in ("stop", "status", "restart"):
37
+ service_command = commands.add_parser(
38
+ command,
39
+ help={
40
+ "stop": "停止第三方服务",
41
+ "status": "查看第三方服务状态",
42
+ "restart": "重启第三方服务",
43
+ }[command],
44
+ )
45
+ service_command.add_argument("service", choices=_service_names())
46
+ return parser
47
+
48
+
49
+ def main(argv: Sequence[str] | None = None) -> None:
50
+ parser = _parser()
51
+ args = parser.parse_args(argv)
52
+
53
+ try:
54
+ if args.command == "services":
55
+ print("\n".join(_service_names()))
56
+ elif args.command == "install":
57
+ path = _service(args.service).install(force=args.force)
58
+ print(f"installed {args.service}: {path}")
59
+ elif args.command == "start" and args.service != "api":
60
+ _service(args.service).start()
61
+ elif args.command == "stop":
62
+ _service(args.service).stop()
63
+ elif args.command == "status":
64
+ if not _service(args.service).status():
65
+ raise SystemExit(1)
66
+ elif args.command == "restart":
67
+ service = _service(args.service)
68
+ service.stop()
69
+ service.start()
70
+ elif args.command == "start":
71
+ uvicorn.run(
72
+ "funmill.api:app",
73
+ host=SERVICE_BIND_HOST,
74
+ port=FUNMILL_API_PORT,
75
+ )
76
+ else:
77
+ parser.print_help()
78
+ except (OSError, RuntimeError, ValueError) as exc:
79
+ parser.exit(1, f"error: {exc}\n")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
funmill/api/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)
funmill/api/ports.py ADDED
@@ -0,0 +1,3 @@
1
+ SERVICE_BIND_HOST = "0.0.0.0"
2
+ FUNMILL_API_PORT = 8812
3
+ THIRD_PARTY_WEB_PORT = 8813
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.5
2
+ Name: funmill-api
3
+ Version: 0.1.2
4
+ Summary: Backend-neutral task execution API
5
+ Project-URL: Organization, https://github.com/farfarfun
6
+ Project-URL: Repository, https://github.com/farfarfun/funmill-api
7
+ Project-URL: Releases, https://github.com/farfarfun/funmill-api/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 recommended local backend is self-hosted
21
+ [Dagu](https://github.com/dagucloud/dagu), pinned to `v2.16.3`. Windmill remains
22
+ available for existing deployments.
23
+
24
+ ```text
25
+ client -> Funmill /v1 -> TaskBackend -> Dagu
26
+ -> Windmill
27
+ ```
28
+
29
+ Funmill owns the public request and response models. Backend job IDs remain
30
+ opaque strings, and no backend-specific routes or payloads are exposed to clients.
31
+
32
+ ## Start
33
+
34
+ Install the project and the Dagu binary. The installer supports macOS and Linux
35
+ on Intel/AMD and ARM64:
36
+
37
+ ```bash
38
+ uv sync
39
+ uv run funmill install dagu
40
+ ```
41
+
42
+ Start Dagu in the background. It stores state under
43
+ `~/.farfarfun/funmill/services/dagu/data/` and needs no external database:
44
+
45
+ ```bash
46
+ uv run funmill start dagu
47
+ ```
48
+
49
+ The command reports its PID and log path. Open <http://localhost:8813>, then
50
+ start Funmill:
51
+
52
+ ```bash
53
+ FUNMILL_API_KEY='replace-me' \
54
+ FUNMILL_BACKEND=dagu \
55
+ DAGU_URL='http://127.0.0.1:8813' \
56
+ uv run funmill start
57
+ ```
58
+
59
+ Managed HTTP services bind to `0.0.0.0`: Funmill uses port `8812` and the active
60
+ third-party service uses `8813`. Local client URLs still use `127.0.0.1` or
61
+ `localhost`; `0.0.0.0` is a listen address, not a client destination.
62
+
63
+ Dagu starts without authentication. Because it listens on every interface,
64
+ restrict port `8813` with a firewall or enable Dagu authentication. When
65
+ authentication is enabled, set `DAGU_TOKEN` for both `funmill start dagu` and
66
+ `funmill start` so cross-run dependencies can query Dagu from worker processes.
67
+
68
+ Dagu runs submitted source with the service user's host permissions. Keep both
69
+ services private and accept only trusted code; use isolated workers or
70
+ containers before accepting untrusted jobs.
71
+
72
+ The Funmill API port is fixed at `8812`; the active third-party UI/API port is
73
+ fixed at `8813`. OpenAPI docs are at <http://localhost:8812/docs>. All `/v1`
74
+ routes require `X-API-Key`. See the
75
+ [Dagu deployment guide](src/funmill/api/backends/dagu/README.md) or the
76
+ [Windmill deployment guide](src/funmill/api/backends/windmill/README.md) for
77
+ backend-specific setup.
78
+
79
+ Run the end-to-end task and DAG checks with:
80
+
81
+ ```bash
82
+ FUNMILL_API_KEY=the-value-from-env ./scripts/smoke.sh
83
+ ```
84
+
85
+ Set `FUNMILL_CALLBACK_URL` to test callbacks. The URL must be reachable from
86
+ the backend workers.
87
+
88
+ ## API
89
+
90
+ | Operation | Route |
91
+ | --- | --- |
92
+ | Health check | `GET /health` |
93
+ | Submit Python/Bash | `POST /v1/tasks` |
94
+ | Submit a DAG | `POST /v1/workflows` |
95
+ | Status | `GET /v1/tasks/{task_id}` |
96
+ | Logs | `GET /v1/tasks/{task_id}/logs` |
97
+ | Progress | `GET /v1/tasks/{task_id}/progress` |
98
+ | Result | `GET /v1/tasks/{task_id}/result` |
99
+ | Cancel | `POST /v1/tasks/{task_id}/cancel` |
100
+ | Rerun | `POST /v1/tasks/{task_id}/rerun` |
101
+
102
+ `GET /health` is unauthenticated and reports whether the Funmill process and
103
+ its configured backend are reachable; it returns `{"status": "ok", "backend":
104
+ "..."}` on success and a 503 with a `detail` message when the backend is
105
+ unreachable or misconfigured.
106
+
107
+ Submit one task, optionally waiting for existing task IDs:
108
+
109
+ ```json
110
+ {
111
+ "language": "python",
112
+ "source": "def main(value: int):\n return value * 2\n",
113
+ "args": {"value": 21},
114
+ "depends_on": ["EXISTING_TASK_ID"],
115
+ "dependency_timeout_seconds": 3600,
116
+ "retry": {"attempts": 2, "delay_seconds": 5},
117
+ "timeout_seconds": 300,
118
+ "callback_url": "https://example.internal/task-callback"
119
+ }
120
+ ```
121
+
122
+ Submit `A -> [B, C]` as one workflow:
123
+
124
+ ```json
125
+ {
126
+ "tasks": [
127
+ {"key": "a", "language": "python", "source": "def main(): return 1"},
128
+ {"key": "b", "language": "python", "source": "def main(): return 2", "depends_on": ["a"]},
129
+ {"key": "c", "language": "bash", "source": "main() { echo 3; }", "depends_on": ["a"]}
130
+ ]
131
+ }
132
+ ```
133
+
134
+ Dependencies inside a workflow are task keys. Top-level `depends_on` values are
135
+ IDs returned by earlier Funmill submissions. Backends check those dependencies
136
+ from worker jobs, so waiting does not hold the Funmill API process. Failure or
137
+ cancellation of a dependency fails the waiting task.
138
+ Workflow results and callback payloads are objects keyed by every workflow task
139
+ key. Each topological layer is a synchronization barrier; tasks in the same
140
+ layer run in parallel.
141
+
142
+ Callbacks contain `task_id`, `status`, and `payload`; success and failure
143
+ delivery retry three times. Delivery is at least once, so callback receivers
144
+ must be idempotent.
145
+
146
+ ## Python SDK
147
+
148
+ A Python client for the `/v1` routes plus `/health` lives in the separate
149
+ [`funmill-sdk`](https://github.com/farfarfun/funmill-sdk) repository, published
150
+ to PyPI as `funmill` (imported as `funmill.client`):
151
+
152
+ ```bash
153
+ pip install funmill
154
+ ```
155
+
156
+ ```python
157
+ from funmill.client import FunmillClient, TaskSubmit
158
+
159
+ with FunmillClient(base_url="http://127.0.0.1:8812", api_key="replace-me") as client:
160
+ client.health() # {"status": "ok", "backend": "dagu"}
161
+
162
+ accepted = client.submit_task(
163
+ TaskSubmit(language="python", source="def main(): return 21 * 2")
164
+ )
165
+ task = client.get_task(accepted.task_id)
166
+ result = client.get_result(accepted.task_id)
167
+ ```
168
+
169
+ See the `funmill-sdk` README for the full client API and error handling.
170
+
171
+ ## Backends
172
+
173
+ The public contract is `TaskBackend` in `src/funmill/api/backends/base.py`. Backend
174
+ selection uses `FUNMILL_BACKEND`; registration lives in
175
+ `src/funmill/api/backends/__init__.py`, following the same driver pattern as
176
+ `fundrive`. Each third-party adapter lives in its own directory, such as
177
+ `src/funmill/api/backends/windmill/`.
178
+
179
+ Every third-party adapter directory must include a `README.md` covering its
180
+ supported platforms, installation, configuration, startup, verification, and
181
+ security or operational constraints.
182
+
183
+ Every managed HTTP service must bind to `SERVICE_BIND_HOST` (`0.0.0.0`). Every
184
+ third-party service must use the shared background lifecycle in
185
+ `src/funmill/api/backends/service.py` and expose `start`, `stop`, and `status`;
186
+ `restart` is composed from `stop` and `start` by the CLI.
187
+
188
+ Changing the backend does not change `/v1`, but it does not migrate old jobs or
189
+ their IDs. Add a Funmill-owned ID mapping database only when jobs must remain
190
+ queryable after a live backend migration.
191
+
192
+ ## Operations
193
+
194
+ ```bash
195
+ funmill services
196
+ funmill install dagu
197
+ funmill start dagu
198
+ funmill status dagu
199
+ funmill restart dagu
200
+ funmill stop dagu
201
+ funmill install windmill
202
+ funmill start windmill
203
+ funmill start
204
+ ```
205
+
206
+ Third-party services run in the background with PID and log files under
207
+ `~/.farfarfun/funmill/services/<service>/`; replace `dagu` with `windmill` in
208
+ the lifecycle commands as needed. The Funmill API remains in the foreground and
209
+ stops with `Ctrl+C`. Add authentication, TLS, firewall rules, PostgreSQL
210
+ backups, callback egress restrictions, and a secrets manager before network
211
+ exposure.
@@ -0,0 +1,18 @@
1
+ funmill/api/__init__.py,sha256=bOFRmVJ0Fum8eBNn31EyOkjpDwqUzlQmbHOa2kArp9k,765
2
+ funmill/api/app.py,sha256=McZUCWKuQho1Di4001qzD8MbuQ-7U0sAlE8kJYaKZqg,3145
3
+ funmill/api/cli.py,sha256=MO2iFLOsjny6JBAiTUTbtczHfL-vKn_iqDonc1CBNeo,2751
4
+ funmill/api/models.py,sha256=c0R-gJi2Tfo8TZKDz-Gwmd5Qd4fGmrNE_n5AjV2jmzM,3756
5
+ funmill/api/ports.py,sha256=xfBREmyI6vBOhF_E6HQgUDjysSRzy_0GPfjfua7vHRI,82
6
+ funmill/api/backends/__init__.py,sha256=ZXD-BqF4t59W_AhoLV3l20n0MxBJEM6zrPz6TrtxP4k,1066
7
+ funmill/api/backends/base.py,sha256=8hXbv3Bw3wJ4WF75MZqf7hA-ZRKpKgjDNFhzlRFyL7M,1208
8
+ funmill/api/backends/service.py,sha256=8OEb4Uepo3l6fudscVjYfhO9ISYWQIy-ANNwOmxVWLk,3122
9
+ funmill/api/backends/dagu/README.md,sha256=wzER_C0XPDILL-Urlgh5uvIDjU2RUAfBteK-2qKdc04,2945
10
+ funmill/api/backends/dagu/__init__.py,sha256=27eRanZ2aX_46_T6uPMheKxewWCGOz2GsTioOx8sdQg,16407
11
+ funmill/api/backends/dagu/service.py,sha256=7ddkGzOVX1bfxHOzNVZ3mG_mjXSDya2i9ltoxwcqlME,5147
12
+ funmill/api/backends/windmill/README.md,sha256=nifEkIMoqXr77Jct7ZrET9tuwtwUkmlRLTDK7OcGt8Y,3437
13
+ funmill/api/backends/windmill/__init__.py,sha256=o5j3Yi9TNuY9w6wW4REaq5VEQSlDf1aAgmqzdACF05s,14689
14
+ funmill/api/backends/windmill/service.py,sha256=gf7ZVPHVW6JXSipbkTHmc3U8KN4qA0Ulk2fHe-bEzco,4887
15
+ funmill_api-0.1.2.dist-info/METADATA,sha256=q-xGYmlStuU1eriIDjslACmBFlxnSFXLZ1Q_jLJUvFk,7435
16
+ funmill_api-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ funmill_api-0.1.2.dist-info/entry_points.txt,sha256=O6hMrrwUkrlQDnKfgSs_joamaUzVrfxfH2_gyfO0Nrg,49
18
+ funmill_api-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ funmill = funmill.api.cli:main