ufaflow 1.0.6__tar.gz

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.
ufaflow-1.0.6/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 halif
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ufaflow-1.0.6/PKG-INFO ADDED
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: ufaflow
3
+ Version: 1.0.6
4
+ Summary: UfaFlow
5
+ Author: halif
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: PyYAML>=6.0
10
+ Dynamic: author
11
+ Dynamic: license-file
12
+
13
+ <h1 align="center">UfaFlow</h1>
14
+
15
+ <p align="center">
16
+ <img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
17
+ <img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
18
+ <img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
19
+ <img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
20
+ <img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
21
+ </p>
22
+
23
+ <p align="center">
24
+ <a href="https://github.com/halif/ufaflow/actions">
25
+ <img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
26
+ </a>
27
+ <img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
28
+ <img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
29
+ <img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
30
+ <img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
31
+ <img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
32
+ </p>
33
+
34
+ ## Overview
35
+
36
+ UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
37
+
38
+ The project is designed for teams that need:
39
+ - a simple worker runtime
40
+ - process-level isolation
41
+ - async-first execution
42
+ - predictable startup and shutdown behavior
43
+ - easy deployment in development and production-like environments
44
+
45
+ ## Why UfaFlow
46
+
47
+ Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
48
+ - manage multiple workers with a single runtime
49
+ - balance traffic or task distribution across processes
50
+ - keep lifecycle management explicit and controllable
51
+ - reduce operational overhead for small-to-medium service deployments
52
+
53
+ ## Key Features
54
+
55
+ - Async-first worker orchestration
56
+ - Configurable worker pool and process management
57
+ - Lightweight request balancing across workers
58
+ - Support for FastAPI and Python service integration
59
+ - YAML-based configuration
60
+ - Clean startup and shutdown lifecycle behavior
61
+ - Simple deployment model for local and cloud environments
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install ufaflow
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ Create a YAML config file:
72
+
73
+ ```yaml
74
+ health_interval: 5
75
+ health_timeout: 2
76
+ frontends:
77
+ - listen_host: "0.0.0.0"
78
+ listen_port: 8080
79
+ algorithm: round_robin
80
+ backends:
81
+ - host: "127.0.0.1"
82
+ port: 8001
83
+ - host: "127.0.0.1"
84
+ port: 8002
85
+ ```
86
+
87
+ Run the load balancer:
88
+
89
+ ```bash
90
+ python -m ufaflow.cli config.yaml
91
+ ```
92
+
93
+ To run with multiple worker processes:
94
+
95
+ ```bash
96
+ python -m ufaflow.cli config.yaml --workers 4
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,89 @@
1
+ <h1 align="center">UfaFlow</h1>
2
+
3
+ <p align="center">
4
+ <img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
5
+ <img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
6
+ <img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
7
+ <img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
8
+ <img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/halif/ufaflow/actions">
13
+ <img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
14
+ </a>
15
+ <img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
16
+ <img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
17
+ <img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
18
+ <img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
19
+ <img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
20
+ </p>
21
+
22
+ ## Overview
23
+
24
+ UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
25
+
26
+ The project is designed for teams that need:
27
+ - a simple worker runtime
28
+ - process-level isolation
29
+ - async-first execution
30
+ - predictable startup and shutdown behavior
31
+ - easy deployment in development and production-like environments
32
+
33
+ ## Why UfaFlow
34
+
35
+ Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
36
+ - manage multiple workers with a single runtime
37
+ - balance traffic or task distribution across processes
38
+ - keep lifecycle management explicit and controllable
39
+ - reduce operational overhead for small-to-medium service deployments
40
+
41
+ ## Key Features
42
+
43
+ - Async-first worker orchestration
44
+ - Configurable worker pool and process management
45
+ - Lightweight request balancing across workers
46
+ - Support for FastAPI and Python service integration
47
+ - YAML-based configuration
48
+ - Clean startup and shutdown lifecycle behavior
49
+ - Simple deployment model for local and cloud environments
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ pip install ufaflow
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ Create a YAML config file:
60
+
61
+ ```yaml
62
+ health_interval: 5
63
+ health_timeout: 2
64
+ frontends:
65
+ - listen_host: "0.0.0.0"
66
+ listen_port: 8080
67
+ algorithm: round_robin
68
+ backends:
69
+ - host: "127.0.0.1"
70
+ port: 8001
71
+ - host: "127.0.0.1"
72
+ port: 8002
73
+ ```
74
+
75
+ Run the load balancer:
76
+
77
+ ```bash
78
+ python -m ufaflow.cli config.yaml
79
+ ```
80
+
81
+ To run with multiple worker processes:
82
+
83
+ ```bash
84
+ python -m ufaflow.cli config.yaml --workers 4
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ufaflow"
7
+ version = "1.0.6"
8
+ description = "UfaFlow"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "PyYAML>=6.0",
13
+ ]
14
+
15
+ [tool.setuptools]
16
+ packages = ["ufaflow"]
17
+
18
+ [tool.black]
19
+ line-length = 88
20
+ target-version = ["py311"]
21
+
22
+ [tool.isort]
23
+ profile = "black"
24
+ line_length = 88
25
+
26
+ [tool.ruff]
27
+ line-length = 88
28
+ fix = true
29
+
30
+ [tool.ruff.lint]
31
+ select = ["E", "F", "W", "I", "B", "C90"]
32
+ ignore = ["C901"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
ufaflow-1.0.6/setup.py ADDED
@@ -0,0 +1,15 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="ufaflow",
5
+ version="0.1.0",
6
+ description="UfaFlow — lightweight L4/L7 load balancer (MVP)",
7
+ packages=find_packages(),
8
+ install_requires=[
9
+ "PyYAML",
10
+ # optional: uvloop improves async performance if installed
11
+ ],
12
+ entry_points={"console_scripts": ["ufaflow=ufaflow.cli:main"]},
13
+ author="halif",
14
+ license="MIT",
15
+ )
@@ -0,0 +1,19 @@
1
+ from ufaflow.server import Backend, BackendPool
2
+
3
+
4
+ def test_round_robin_simple():
5
+ b1 = Backend("127.0.0.1", 8001)
6
+ b2 = Backend("127.0.0.1", 8002)
7
+ pool = BackendPool([b1, b2], algorithm="round_robin")
8
+ seq = [pool.choose().name for _ in range(4)]
9
+ assert seq[0] != seq[1]
10
+ assert len(set(seq)) == 2
11
+
12
+
13
+ def test_least_conn():
14
+ b1 = Backend("127.0.0.1", 8001)
15
+ b2 = Backend("127.0.0.1", 8002)
16
+ b2.current_conns = 5
17
+ pool = BackendPool([b1, b2], algorithm="least_conn")
18
+ chosen = pool.choose()
19
+ assert chosen is b1
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ import sys
4
+
5
+ from ufaflow import __version__
6
+ from ufaflow.cli import validate_config
7
+
8
+
9
+ def test_package_version_is_exposed():
10
+ assert __version__
11
+ assert isinstance(__version__, str)
12
+ assert __version__.count(".") >= 1
13
+
14
+
15
+ def test_validate_config_accepts_valid_config():
16
+ config = {
17
+ "frontends": [
18
+ {
19
+ "listen_host": "0.0.0.0",
20
+ "listen_port": 8080,
21
+ "backends": [
22
+ {"host": "127.0.0.1", "port": 8001},
23
+ {"host": "127.0.0.1", "port": 8002},
24
+ ],
25
+ }
26
+ ]
27
+ }
28
+
29
+ validated = validate_config(config)
30
+
31
+ assert validated["frontends"][0]["listen_port"] == 8080
32
+ assert len(validated["frontends"][0]["backends"]) == 2
33
+
34
+
35
+ def test_cli_help_runs_successfully():
36
+ result = subprocess.run(
37
+ [sys.executable, "-m", "ufaflow.cli", "--help"],
38
+ capture_output=True,
39
+ text=True,
40
+ check=False,
41
+ )
42
+
43
+ assert result.returncode == 0
44
+ assert "usage:" in result.stdout.lower()
45
+ assert "UfaFlow" in result.stdout
@@ -0,0 +1,5 @@
1
+ """
2
+ UfaFlow package
3
+ """
4
+
5
+ __version__ = "1.0.5"
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI / process manager for UfaFlow
4
+
5
+ Supports:
6
+ - single-process run: python -m ufaflow.cli config.yaml
7
+ - worker-mode: python -m ufaflow.cli config.yaml --workers N
8
+
9
+ Manager will spawn N workers; each worker binds sockets with SO_REUSEPORT
10
+ (if available). SIGHUP on manager -> restart workers (reload config).
11
+ SIGTERM/SIGINT -> graceful shutdown.
12
+ """
13
+
14
+ import argparse
15
+ import logging
16
+ import multiprocessing as mp
17
+ import os
18
+ import signal
19
+ import socket
20
+ import sys
21
+ import time
22
+ from typing import Any, List
23
+
24
+ import yaml
25
+
26
+ # try to use uvloop in workers
27
+ try:
28
+ import uvloop # type: ignore
29
+ except Exception:
30
+ uvloop = None
31
+
32
+ from . import server as server_mod
33
+
34
+ logging.basicConfig(
35
+ level=logging.INFO,
36
+ format="%(asctime)s %(levelname)s %(message)s",
37
+ )
38
+
39
+
40
+ def validate_backend(
41
+ backend: Any,
42
+ frontend_index: int,
43
+ backend_index: int,
44
+ ) -> None:
45
+ if not isinstance(backend, dict):
46
+ raise ValueError(
47
+ f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
48
+ "expected a mapping with host and port."
49
+ )
50
+
51
+ if "host" not in backend or "port" not in backend:
52
+ raise ValueError(
53
+ f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
54
+ "must include 'host' and 'port'."
55
+ )
56
+
57
+ if not isinstance(backend["host"], str) or not backend["host"].strip():
58
+ raise ValueError(
59
+ f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
60
+ "'host' must be a non-empty string."
61
+ )
62
+
63
+ port = backend["port"]
64
+ if not isinstance(port, int) or port <= 0 or port > 65535:
65
+ raise ValueError(
66
+ f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
67
+ "'port' must be an integer between 1 and 65535."
68
+ )
69
+
70
+
71
+ def validate_frontend(frontend: Any, index: int) -> None:
72
+ if not isinstance(frontend, dict):
73
+ raise ValueError(f"Invalid frontend[{index}]: expected a mapping.")
74
+
75
+ if "listen_port" not in frontend:
76
+ raise ValueError(f"Invalid frontend[{index}]: missing 'listen_port'.")
77
+
78
+ port = frontend["listen_port"]
79
+ if not isinstance(port, int) or port <= 0 or port > 65535:
80
+ raise ValueError(
81
+ f"Invalid frontend[{index}]: 'listen_port' must be an integer "
82
+ "between 1 and 65535."
83
+ )
84
+
85
+ backends = frontend.get("backends", [])
86
+ if not isinstance(backends, list) or not backends:
87
+ raise ValueError(
88
+ f"Invalid frontend[{index}]: 'backends' must be a non-empty list."
89
+ )
90
+
91
+ for backend_index, backend in enumerate(backends):
92
+ validate_backend(backend, index, backend_index)
93
+
94
+
95
+ def validate_config(config: Any) -> dict:
96
+ if not isinstance(config, dict):
97
+ raise ValueError("Configuration must be a mapping/object.")
98
+
99
+ frontends = config.get("frontends")
100
+ if not isinstance(frontends, list) or not frontends:
101
+ raise ValueError("Configuration error: 'frontends' must be a non-empty list.")
102
+
103
+ for index, frontend in enumerate(frontends):
104
+ validate_frontend(frontend, index)
105
+
106
+ return config
107
+
108
+
109
+ def load_config(path: str):
110
+ try:
111
+ with open(path, "r", encoding="utf-8") as f:
112
+ config = yaml.safe_load(f)
113
+ except FileNotFoundError as exc:
114
+ raise ValueError(f"Config file not found: {path}") from exc
115
+ except yaml.YAMLError as exc:
116
+ raise ValueError(f"Invalid YAML in config file '{path}': {exc}") from exc
117
+
118
+ return validate_config(config)
119
+
120
+
121
+ def create_listen_sockets(frontends: list) -> List[socket.socket]:
122
+ socks = []
123
+ for fe in frontends:
124
+ host = fe.get("listen_host", "0.0.0.0")
125
+ port = fe["listen_port"]
126
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
127
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
128
+ # try to set SO_REUSEPORT; may raise AttributeError on some platforms
129
+ try:
130
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
131
+ logging.info("Using SO_REUSEPORT on %s:%d", host, port)
132
+ except AttributeError:
133
+ logging.warning(
134
+ "SO_REUSEPORT not supported on this platform; multi-process "
135
+ "gain limited"
136
+ )
137
+ except OSError:
138
+ logging.warning(
139
+ "SO_REUSEPORT not available / permission denied; continuing "
140
+ "without it"
141
+ )
142
+ sock.bind((host, port))
143
+ sock.listen(1000)
144
+ sock.setblocking(False)
145
+ socks.append(sock)
146
+ return socks
147
+
148
+
149
+ def worker_main(config_path: str):
150
+ # Worker process entrypoint
151
+ if uvloop:
152
+ asyncio_policy = uvloop.EventLoopPolicy()
153
+ import asyncio as _asyncio
154
+
155
+ _asyncio.set_event_loop_policy(asyncio_policy)
156
+
157
+ import asyncio
158
+
159
+ config = load_config(config_path)
160
+ frontends = config.get("frontends", [])
161
+
162
+ # In worker mode we create sockets independently (each worker must set
163
+ # SO_REUSEPORT before bind), but manager may also create sockets and
164
+ # pass FDs via environment variable.
165
+ fds_env = os.environ.get("UFAFLOW_LISTEN_FDS")
166
+ if fds_env:
167
+ fds = [int(x) for x in fds_env.split(",") if x.strip()]
168
+ socks = [socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) for fd in fds]
169
+ # set non-blocking
170
+ for s in socks:
171
+ s.setblocking(False)
172
+ else:
173
+ # fallback: each worker creates its own sockets (SO_REUSEPORT set by
174
+ # manager earlier)
175
+ socks = create_listen_sockets(frontends)
176
+
177
+ async def _run():
178
+ try:
179
+ await server_mod.run_worker_with_sockets(config, socks)
180
+ except Exception as ex:
181
+ logging.exception("Worker encountered exception: %s", ex)
182
+ raise
183
+
184
+ loop = asyncio.new_event_loop()
185
+ asyncio.set_event_loop(loop)
186
+
187
+ # graceful shutdown handler
188
+ stop = False
189
+
190
+ def _on_term(signum, frame):
191
+ nonlocal stop
192
+ logging.info("Worker got signal %s, stopping...", signum)
193
+ stop = True
194
+ for task in asyncio.all_tasks(loop):
195
+ task.cancel()
196
+
197
+ signal.signal(signal.SIGINT, _on_term)
198
+ signal.signal(signal.SIGTERM, _on_term)
199
+
200
+ try:
201
+ loop.run_until_complete(_run())
202
+ except KeyboardInterrupt:
203
+ pass
204
+ finally:
205
+ loop.run_until_complete(loop.shutdown_asyncgens())
206
+ loop.close()
207
+ logging.info("Worker exiting")
208
+
209
+
210
+ def run_manager(config_path: str, workers: int): # noqa: C901
211
+ """
212
+ Manager: spawn `workers` worker processes. On SIGHUP restart workers
213
+ (reload config).
214
+ """
215
+ config = load_config(config_path)
216
+ frontends = config.get("frontends", [])
217
+ # Manager will set env UFAFLOW_LISTEN_FDS with fds of created sockets
218
+ # (so workers can inherit).
219
+ socks = create_listen_sockets(frontends)
220
+ fds = [s.fileno() for s in socks]
221
+ fds_str = ",".join(str(fd) for fd in fds)
222
+
223
+ # children: mapping pid -> Process
224
+ children = {}
225
+
226
+ def spawn_workers():
227
+ logging.info("Spawning %d workers...", workers)
228
+ for _ in range(workers):
229
+ env = os.environ.copy()
230
+ env["UFAFLOW_LISTEN_FDS"] = fds_str
231
+ p = mp.Process(
232
+ target=_worker_process_wrapper,
233
+ args=(config_path,),
234
+ env=env,
235
+ )
236
+ p.start()
237
+ children[p.pid] = p
238
+ logging.info("Spawned worker pid=%d", p.pid)
239
+
240
+ def stop_workers():
241
+ logging.info("Stopping workers...")
242
+ for pid, p in list(children.items()):
243
+ logging.info("Terminating worker pid=%d", pid)
244
+ p.terminate()
245
+ # join/ensure termination
246
+ for pid, p in list(children.items()):
247
+ p.join(timeout=5)
248
+ if p.is_alive():
249
+ logging.info("Killing worker pid=%d", pid)
250
+ p.kill()
251
+ children.pop(pid, None)
252
+
253
+ def restart_workers():
254
+ logging.info("Restarting workers (SIGHUP)...")
255
+ stop_workers()
256
+ spawn_workers()
257
+
258
+ # SIGHUP -> restart
259
+ def _on_sighup(signum, frame):
260
+ logging.info("Manager received SIGHUP")
261
+ restart_workers()
262
+
263
+ def _on_term(signum, frame):
264
+ logging.info("Manager received SIGTERM/SIGINT, shutting down")
265
+ stop_workers()
266
+ sys.exit(0)
267
+
268
+ signal.signal(signal.SIGHUP, _on_sighup)
269
+ signal.signal(signal.SIGINT, _on_term)
270
+ signal.signal(signal.SIGTERM, _on_term)
271
+
272
+ spawn_workers()
273
+ # Wait for children; respawn workers if they exit unexpectedly
274
+ try:
275
+ while True:
276
+ time.sleep(1)
277
+ # reap dead children and respawn
278
+ for pid, p in list(children.items()):
279
+ if not p.is_alive():
280
+ logging.warning(
281
+ "Worker pid=%d exited unexpectedly; restarting", pid
282
+ )
283
+ children.pop(pid, None)
284
+ p.join()
285
+ env = os.environ.copy()
286
+ env["UFAFLOW_LISTEN_FDS"] = fds_str
287
+ p2 = mp.Process(
288
+ target=_worker_process_wrapper,
289
+ args=(config_path,),
290
+ env=env,
291
+ )
292
+ p2.start()
293
+ children[p2.pid] = p2
294
+ logging.info("Respawned worker pid=%d", p2.pid)
295
+ except KeyboardInterrupt:
296
+ logging.info("Manager got KeyboardInterrupt, stopping workers")
297
+ stop_workers()
298
+
299
+
300
+ def _worker_process_wrapper(config_path: str):
301
+ # Runs in child process created by multiprocessing.Process. Child reads
302
+ # UFAFLOW_LISTEN_FDS from os.environ (safer across platforms).
303
+ worker_main(config_path)
304
+
305
+
306
+ def run_single(config_path: str): # noqa: C901
307
+ # single-process run (no manager)
308
+ if uvloop:
309
+ import asyncio as _asyncio
310
+
311
+ _asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
312
+ import asyncio
313
+
314
+ config = load_config(config_path)
315
+
316
+ async def _run():
317
+ servers, tasks = await server_mod.start_servers_from_config(config)
318
+ active_servers = []
319
+ # start servers normally (no external sockets)
320
+ for host, port, pool in servers:
321
+ srv = await asyncio.start_server(
322
+ lambda r, w, p=pool: server_mod.handle_client(r, w, p),
323
+ host=host,
324
+ port=port,
325
+ )
326
+ logging.info("Listening on %s:%d", host, port)
327
+ active_servers.append(srv)
328
+ try:
329
+ await asyncio.gather(
330
+ *[s.serve_forever() for s in active_servers],
331
+ *tasks,
332
+ )
333
+ except asyncio.CancelledError:
334
+ logging.info("Shutting down single-process server")
335
+ finally:
336
+ for s in active_servers:
337
+ s.close()
338
+ await s.wait_closed()
339
+ for t in tasks:
340
+ t.cancel()
341
+ try:
342
+ await t
343
+ except Exception:
344
+ pass
345
+
346
+ loop = asyncio.new_event_loop()
347
+ asyncio.set_event_loop(loop)
348
+
349
+ def _on_term(signum, frame):
350
+ logging.info("Single-process received term, cancelling tasks")
351
+ for task in asyncio.all_tasks(loop):
352
+ task.cancel()
353
+
354
+ signal.signal(signal.SIGINT, _on_term)
355
+ signal.signal(signal.SIGTERM, _on_term)
356
+
357
+ try:
358
+ loop.run_until_complete(_run())
359
+ except KeyboardInterrupt:
360
+ pass
361
+ finally:
362
+ loop.run_until_complete(loop.shutdown_asyncgens())
363
+ loop.close()
364
+
365
+
366
+ def main():
367
+ parser = argparse.ArgumentParser(description="UfaFlow load balancer")
368
+ parser.add_argument("config", help="Path to YAML config")
369
+ parser.add_argument(
370
+ "--workers",
371
+ "-w",
372
+ type=int,
373
+ default=1,
374
+ help="Number of worker processes (default 1)",
375
+ )
376
+ args = parser.parse_args()
377
+
378
+ if args.workers < 1:
379
+ parser.error("--workers must be greater than or equal to 1")
380
+
381
+ try:
382
+ if args.workers <= 1:
383
+ run_single(args.config)
384
+ else:
385
+ run_manager(args.config, args.workers)
386
+ except ValueError as exc:
387
+ print(f"Configuration error: {exc}", file=sys.stderr)
388
+ raise SystemExit(1) from exc
389
+ except FileNotFoundError as exc:
390
+ print(f"Configuration file not found: {exc.filename}", file=sys.stderr)
391
+ raise SystemExit(1) from exc
392
+
393
+
394
+ if __name__ == "__main__":
395
+ main()
@@ -0,0 +1,260 @@
1
+ import asyncio
2
+ import logging
3
+ import socket
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import List, Optional
7
+
8
+ logging.getLogger().setLevel(logging.INFO)
9
+
10
+
11
+ @dataclass
12
+ class Backend:
13
+ host: str
14
+ port: int
15
+ weight: int = 1
16
+ name: Optional[str] = None
17
+ up: bool = True
18
+ current_conns: int = 0
19
+ last_checked: float = 0.0
20
+
21
+ def __post_init__(self):
22
+ if not self.name:
23
+ self.name = f"{self.host}:{self.port}"
24
+
25
+ def addr(self):
26
+ return (self.host, self.port)
27
+
28
+
29
+ class BackendPool:
30
+ def __init__(self, backends: List[Backend], algorithm: str = "round_robin"):
31
+ self.backends = backends
32
+ self.algorithm = algorithm
33
+ self._rr_state = 0
34
+
35
+ def available(self) -> List[Backend]:
36
+ return [b for b in self.backends if b.up]
37
+
38
+ def choose(self, client_ip: Optional[str] = None) -> Optional[Backend]:
39
+ av = self.available()
40
+ if not av:
41
+ return None
42
+ if self.algorithm == "least_conn":
43
+ return min(av, key=lambda b: b.current_conns)
44
+ # simple weighted round-robin
45
+ choices = []
46
+ for b in av:
47
+ choices.extend([b] * max(1, b.weight))
48
+ self._rr_state = (self._rr_state + 1) % len(choices)
49
+ return choices[self._rr_state]
50
+
51
+
52
+ async def relay_stream(
53
+ reader: asyncio.StreamReader,
54
+ writer: asyncio.StreamWriter,
55
+ ):
56
+ try:
57
+ while True:
58
+ data = await reader.read(64 * 1024)
59
+ if not data:
60
+ break
61
+ writer.write(data)
62
+ await writer.drain()
63
+ except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
64
+ pass
65
+ finally:
66
+ try:
67
+ writer.close()
68
+ await writer.wait_closed()
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ async def handle_client(
74
+ client_reader: asyncio.StreamReader,
75
+ client_writer: asyncio.StreamWriter,
76
+ pool: BackendPool,
77
+ timeout: float = 10.0,
78
+ ):
79
+ peer = client_writer.get_extra_info("peername")
80
+ backend = pool.choose(client_ip=peer[0] if peer else None)
81
+ if not backend:
82
+ logging.warning("No available backend for connection from %s", peer)
83
+ try:
84
+ client_writer.close()
85
+ await client_writer.wait_closed()
86
+ except Exception:
87
+ pass
88
+ return
89
+
90
+ logging.info("Proxying %s -> %s", peer, backend.name)
91
+ backend.current_conns += 1
92
+ try:
93
+ try:
94
+ backend_reader, backend_writer = await asyncio.wait_for(
95
+ asyncio.open_connection(backend.host, backend.port),
96
+ timeout=timeout,
97
+ )
98
+ except Exception as e:
99
+ logging.warning("Failed to connect to backend %s: %s", backend.name, e)
100
+ backend.up = False
101
+ return
102
+
103
+ t1 = asyncio.create_task(relay_stream(client_reader, backend_writer))
104
+ t2 = asyncio.create_task(relay_stream(backend_reader, client_writer))
105
+ await asyncio.wait(
106
+ [t1, t2],
107
+ return_when=asyncio.FIRST_COMPLETED,
108
+ )
109
+ for t in (t1, t2):
110
+ if not t.done():
111
+ t.cancel()
112
+ finally:
113
+ backend.current_conns = max(0, backend.current_conns - 1)
114
+ try:
115
+ client_writer.close()
116
+ await client_writer.wait_closed()
117
+ except Exception:
118
+ pass
119
+
120
+
121
+ async def tcp_health_check_periodic(
122
+ backends: List[Backend],
123
+ interval: int,
124
+ timeout: float,
125
+ ):
126
+ while True:
127
+ for b in backends:
128
+ try:
129
+ b.last_checked = time.time()
130
+ fut = asyncio.open_connection(b.host, b.port)
131
+ r, w = await asyncio.wait_for(fut, timeout=timeout)
132
+ try:
133
+ w.close()
134
+ await w.wait_closed()
135
+ except Exception:
136
+ pass
137
+ if not b.up:
138
+ logging.info("Backend %s is up", b.name)
139
+ b.up = True
140
+ except Exception:
141
+ if b.up:
142
+ logging.warning("Backend %s is down", b.name)
143
+ b.up = False
144
+ await asyncio.sleep(interval)
145
+
146
+
147
+ async def start_servers_from_config(config: dict):
148
+ frontends = config.get("frontends", [])
149
+ pools = []
150
+ tasks = []
151
+ for fe in frontends:
152
+ be_cfgs = fe.get("backends", [])
153
+ backends = [
154
+ Backend(
155
+ b["host"],
156
+ b["port"],
157
+ b.get("weight", 1),
158
+ name=b.get("name"),
159
+ )
160
+ for b in be_cfgs
161
+ ]
162
+ pool = BackendPool(
163
+ backends,
164
+ algorithm=fe.get("algorithm", "round_robin"),
165
+ )
166
+ pools.append((fe, pool))
167
+
168
+ all_backends = [b for _, p in pools for b in p.backends]
169
+ # healthcheck task
170
+ tasks.append(
171
+ asyncio.create_task(
172
+ tcp_health_check_periodic(
173
+ all_backends,
174
+ config.get("health_interval", 5),
175
+ config.get("health_timeout", 2),
176
+ )
177
+ )
178
+ )
179
+ # start frontends
180
+ servers = []
181
+ for fe, pool in pools:
182
+ host = fe.get("listen_host", "0.0.0.0")
183
+ port = fe["listen_port"]
184
+ # We'll create server sockets externally (in worker mode) and pass them
185
+ # into start_server via sock param.
186
+ # For single-process mode, use default start_server (no sock).
187
+ servers.append((host, port, pool))
188
+ return servers, tasks
189
+
190
+
191
+ async def run_worker_with_sockets(config: dict, sockets: List[socket.socket]):
192
+ """
193
+ sockets: list of listening sockets (must correspond to config frontends order)
194
+ """
195
+ # set up uvloop already done by CLI
196
+ frontends = config.get("frontends", [])
197
+ pools = []
198
+ for fe in frontends:
199
+ be_cfgs = fe.get("backends", [])
200
+ backends = [
201
+ Backend(
202
+ b["host"],
203
+ b["port"],
204
+ b.get("weight", 1),
205
+ name=b.get("name"),
206
+ )
207
+ for b in be_cfgs
208
+ ]
209
+ pool = BackendPool(
210
+ backends,
211
+ algorithm=fe.get("algorithm", "round_robin"),
212
+ )
213
+ pools.append((fe, pool))
214
+
215
+ all_backends = [b for _, p in pools for b in p.backends]
216
+ tasks = []
217
+ tasks.append(
218
+ asyncio.create_task(
219
+ tcp_health_check_periodic(
220
+ all_backends,
221
+ config.get("health_interval", 5),
222
+ config.get("health_timeout", 2),
223
+ )
224
+ )
225
+ )
226
+
227
+ servers = []
228
+ # Create servers using provided sockets (SO_REUSEPORT case)
229
+ for (fe, pool), sock in zip(pools, sockets, strict=False):
230
+ server = await asyncio.start_server(
231
+ lambda r, w, p=pool: handle_client(r, w, p),
232
+ sock=sock,
233
+ )
234
+ addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
235
+ logging.info(
236
+ "Worker listening on %s (frontend %s:%d)",
237
+ addrs,
238
+ fe.get("listen_host", "0.0.0.0"),
239
+ fe["listen_port"],
240
+ )
241
+ servers.append(server)
242
+
243
+ # Wait until cancelled
244
+ try:
245
+ await asyncio.gather(*[s.serve_forever() for s in servers], *tasks)
246
+ except asyncio.CancelledError:
247
+ logging.info("Worker cancelled, shutting down servers")
248
+ finally:
249
+ for s in servers:
250
+ s.close()
251
+ try:
252
+ await s.wait_closed()
253
+ except Exception:
254
+ pass
255
+ for t in tasks:
256
+ t.cancel()
257
+ try:
258
+ await t
259
+ except Exception:
260
+ pass
@@ -0,0 +1,232 @@
1
+ import asyncio
2
+ import logging
3
+ import socket
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import List, Optional
7
+
8
+ logging.getLogger().setLevel(logging.INFO)
9
+
10
+
11
+ @dataclass
12
+ class Backend:
13
+ host: str
14
+ port: int
15
+ weight: int = 1
16
+ name: Optional[str] = None
17
+ up: bool = True
18
+ current_conns: int = 0
19
+ last_checked: float = 0.0
20
+
21
+ def __post_init__(self):
22
+ if not self.name:
23
+ self.name = f"{self.host}:{self.port}"
24
+
25
+ def addr(self):
26
+ return (self.host, self.port)
27
+
28
+
29
+ class BackendPool:
30
+ def __init__(self, backends: List[Backend], algorithm: str = "round_robin"):
31
+ self.backends = backends
32
+ self.algorithm = algorithm
33
+ self._rr_state = 0
34
+
35
+ def available(self) -> List[Backend]:
36
+ return [b for b in self.backends if b.up]
37
+
38
+ def choose(self, client_ip: Optional[str] = None) -> Optional[Backend]:
39
+ av = self.available()
40
+ if not av:
41
+ return None
42
+ if self.algorithm == "least_conn":
43
+ return min(av, key=lambda b: b.current_conns)
44
+ # simple weighted round-robin
45
+ choices = []
46
+ for b in av:
47
+ choices.extend([b] * max(1, b.weight))
48
+ self._rr_state = (self._rr_state + 1) % len(choices)
49
+ return choices[self._rr_state]
50
+
51
+
52
+ async def relay_stream(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
53
+ try:
54
+ while True:
55
+ data = await reader.read(64 * 1024)
56
+ if not data:
57
+ break
58
+ writer.write(data)
59
+ await writer.drain()
60
+ except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
61
+ pass
62
+ finally:
63
+ try:
64
+ writer.close()
65
+ await writer.wait_closed()
66
+ except Exception:
67
+ pass
68
+
69
+
70
+ async def handle_client(
71
+ client_reader: asyncio.StreamReader,
72
+ client_writer: asyncio.StreamWriter,
73
+ pool: BackendPool,
74
+ timeout: float = 10.0,
75
+ ):
76
+ peer = client_writer.get_extra_info("peername")
77
+ backend = pool.choose(client_ip=peer[0] if peer else None)
78
+ if not backend:
79
+ logging.warning("No available backend for connection from %s", peer)
80
+ try:
81
+ client_writer.close()
82
+ await client_writer.wait_closed()
83
+ except Exception:
84
+ pass
85
+ return
86
+ logging.info("Proxying %s -> %s", peer, backend.name)
87
+ backend.current_conns += 1
88
+ try:
89
+ try:
90
+ backend_reader, backend_writer = await asyncio.wait_for(
91
+ asyncio.open_connection(backend.host, backend.port), timeout=timeout
92
+ )
93
+ except Exception as e:
94
+ logging.warning("Failed to connect to backend %s: %s", backend.name, e)
95
+ backend.up = False
96
+ return
97
+ t1 = asyncio.create_task(relay_stream(client_reader, backend_writer))
98
+ t2 = asyncio.create_task(relay_stream(backend_reader, client_writer))
99
+ await asyncio.wait([t1, t2], return_when=asyncio.FIRST_COMPLETED)
100
+ for t in (t1, t2):
101
+ if not t.done():
102
+ t.cancel()
103
+ finally:
104
+ backend.current_conns = max(0, backend.current_conns - 1)
105
+ try:
106
+ client_writer.close()
107
+ await client_writer.wait_closed()
108
+ except Exception:
109
+ pass
110
+
111
+
112
+ async def tcp_health_check_periodic(
113
+ backends: List[Backend], interval: int, timeout: float
114
+ ):
115
+ while True:
116
+ for b in backends:
117
+ try:
118
+ b.last_checked = time.time()
119
+ fut = asyncio.open_connection(b.host, b.port)
120
+ r, w = await asyncio.wait_for(fut, timeout=timeout)
121
+ try:
122
+ w.close()
123
+ await w.wait_closed()
124
+ except Exception:
125
+ pass
126
+ if not b.up:
127
+ logging.info("Backend %s is up", b.name)
128
+ b.up = True
129
+ except Exception:
130
+ if b.up:
131
+ logging.warning("Backend %s is down", b.name)
132
+ b.up = False
133
+ await asyncio.sleep(interval)
134
+
135
+
136
+ async def start_servers_from_config(config: dict):
137
+ frontends = config.get("frontends", [])
138
+ pools = []
139
+ tasks = []
140
+ for fe in frontends:
141
+ be_cfgs = fe.get("backends", [])
142
+ backends = [
143
+ Backend(b["host"], b["port"], b.get("weight", 1), name=b.get("name"))
144
+ for b in be_cfgs
145
+ ]
146
+ pool = BackendPool(backends, algorithm=fe.get("algorithm", "round_robin"))
147
+ pools.append((fe, pool))
148
+
149
+ all_backends = [b for _, p in pools for b in p.backends]
150
+ # healthcheck task
151
+ tasks.append(
152
+ asyncio.create_task(
153
+ tcp_health_check_periodic(
154
+ all_backends,
155
+ config.get("health_interval", 5),
156
+ config.get("health_timeout", 2),
157
+ )
158
+ )
159
+ )
160
+ # start frontends
161
+ servers = []
162
+ for fe, pool in pools:
163
+ host = fe.get("listen_host", "0.0.0.0")
164
+ port = fe["listen_port"]
165
+ # We'll create server sockets externally (in worker mode) and pass them
166
+ # into start_server via sock param.
167
+ # For single-process mode, use default start_server (no sock).
168
+ servers.append((host, port, pool))
169
+ return servers, tasks
170
+
171
+
172
+ async def run_worker_with_sockets(config: dict, sockets: List[socket.socket]):
173
+ """
174
+ sockets: list of listening sockets (must correspond to config frontends order)
175
+ """
176
+ # set up uvloop already done by CLI
177
+ frontends = config.get("frontends", [])
178
+ pools = []
179
+ for fe in frontends:
180
+ be_cfgs = fe.get("backends", [])
181
+ backends = [
182
+ Backend(b["host"], b["port"], b.get("weight", 1), name=b.get("name"))
183
+ for b in be_cfgs
184
+ ]
185
+ pool = BackendPool(backends, algorithm=fe.get("algorithm", "round_robin"))
186
+ pools.append((fe, pool))
187
+
188
+ all_backends = [b for _, p in pools for b in p.backends]
189
+ tasks = []
190
+ tasks.append(
191
+ asyncio.create_task(
192
+ tcp_health_check_periodic(
193
+ all_backends,
194
+ config.get("health_interval", 5),
195
+ config.get("health_timeout", 2),
196
+ )
197
+ )
198
+ )
199
+
200
+ servers = []
201
+ # Create servers using provided sockets (SO_REUSEPORT case)
202
+ for (fe, pool), sock in zip(pools, sockets, strict=False):
203
+ server = await asyncio.start_server(
204
+ lambda r, w, p=pool: handle_client(r, w, p), sock=sock
205
+ )
206
+ addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
207
+ logging.info(
208
+ "Worker listening on %s (frontend %s:%d)",
209
+ addrs,
210
+ fe.get("listen_host", "0.0.0.0"),
211
+ fe["listen_port"],
212
+ )
213
+ servers.append(server)
214
+
215
+ # Wait until cancelled
216
+ try:
217
+ await asyncio.gather(*[s.serve_forever() for s in servers], *tasks)
218
+ except asyncio.CancelledError:
219
+ logging.info("Worker cancelled, shutting down servers")
220
+ finally:
221
+ for s in servers:
222
+ s.close()
223
+ try:
224
+ await s.wait_closed()
225
+ except Exception:
226
+ pass
227
+ for t in tasks:
228
+ t.cancel()
229
+ try:
230
+ await t
231
+ except Exception:
232
+ pass
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: ufaflow
3
+ Version: 1.0.6
4
+ Summary: UfaFlow
5
+ Author: halif
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: PyYAML>=6.0
10
+ Dynamic: author
11
+ Dynamic: license-file
12
+
13
+ <h1 align="center">UfaFlow</h1>
14
+
15
+ <p align="center">
16
+ <img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
17
+ <img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
18
+ <img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
19
+ <img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
20
+ <img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
21
+ </p>
22
+
23
+ <p align="center">
24
+ <a href="https://github.com/halif/ufaflow/actions">
25
+ <img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
26
+ </a>
27
+ <img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
28
+ <img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
29
+ <img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
30
+ <img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
31
+ <img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
32
+ </p>
33
+
34
+ ## Overview
35
+
36
+ UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
37
+
38
+ The project is designed for teams that need:
39
+ - a simple worker runtime
40
+ - process-level isolation
41
+ - async-first execution
42
+ - predictable startup and shutdown behavior
43
+ - easy deployment in development and production-like environments
44
+
45
+ ## Why UfaFlow
46
+
47
+ Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
48
+ - manage multiple workers with a single runtime
49
+ - balance traffic or task distribution across processes
50
+ - keep lifecycle management explicit and controllable
51
+ - reduce operational overhead for small-to-medium service deployments
52
+
53
+ ## Key Features
54
+
55
+ - Async-first worker orchestration
56
+ - Configurable worker pool and process management
57
+ - Lightweight request balancing across workers
58
+ - Support for FastAPI and Python service integration
59
+ - YAML-based configuration
60
+ - Clean startup and shutdown lifecycle behavior
61
+ - Simple deployment model for local and cloud environments
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install ufaflow
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ Create a YAML config file:
72
+
73
+ ```yaml
74
+ health_interval: 5
75
+ health_timeout: 2
76
+ frontends:
77
+ - listen_host: "0.0.0.0"
78
+ listen_port: 8080
79
+ algorithm: round_robin
80
+ backends:
81
+ - host: "127.0.0.1"
82
+ port: 8001
83
+ - host: "127.0.0.1"
84
+ port: 8002
85
+ ```
86
+
87
+ Run the load balancer:
88
+
89
+ ```bash
90
+ python -m ufaflow.cli config.yaml
91
+ ```
92
+
93
+ To run with multiple worker processes:
94
+
95
+ ```bash
96
+ python -m ufaflow.cli config.yaml --workers 4
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ tests/test_backend_pool.py
6
+ tests/test_cli_smoke.py
7
+ ufaflow/__init__.py
8
+ ufaflow/cli.py
9
+ ufaflow/server.py
10
+ ufaflow/ufaflow_server.py
11
+ ufaflow.egg-info/PKG-INFO
12
+ ufaflow.egg-info/SOURCES.txt
13
+ ufaflow.egg-info/dependency_links.txt
14
+ ufaflow.egg-info/requires.txt
15
+ ufaflow.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ ufaflow