functualize-http 0.1.0__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,423 @@
1
+ """Functualize HTTP Plugin - HTTP delivery adapter using asyncio.
2
+
3
+ Provides HTTP serving support both as a standalone adapter (HttpAdapter)
4
+ and as a CLI command plugin (HttpServerPlugin). Both share a common
5
+ HttpServerCore class containing route building, request handling, and
6
+ async-to-sync bridging logic.
7
+
8
+ Uses Python's stdlib asyncio for a lightweight HTTP server without
9
+ heavy dependencies (no uvicorn/starlette required).
10
+
11
+ Key design:
12
+ - HttpServerCore: shared class with route building, request handling,
13
+ async-to-sync bridging via asyncio.to_thread()
14
+ - HttpAdapter: satisfies AdapterPlugin Protocol, adapter_type="http"
15
+ - HttpServerPlugin: capability plugin registering a "serve" command
16
+
17
+ The kernel stays synchronous — the adapter owns the event loop internally
18
+ via asyncio.run().
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from asyncio import AbstractEventLoop
32
+
33
+ from functualize.app.core import FunctualizeApp
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PluginMetadata:
40
+ """Metadata for the functualize-http plugin package."""
41
+
42
+ name: str = "functualize-http"
43
+ version: str = "0.1.0"
44
+ description: str = "HTTP delivery adapter plugin for functualize using asyncio"
45
+
46
+
47
+ class HttpServerCore:
48
+ """Shared HTTP server logic for route building and request handling.
49
+
50
+ Contains:
51
+ - Route building: maps each job to POST /jobs/{job_name}/execute
52
+ - Request handling: parse JSON body as kwargs, call app.execute()
53
+ - Async-to-sync bridging: asyncio.to_thread() for kernel execution
54
+ - Response formatting: JSON response with status and result
55
+ - Health endpoint: GET /health returns 200
56
+ - Job listing: GET /jobs returns available jobs
57
+
58
+ Both HttpAdapter and HttpServerPlugin use this class internally
59
+ to avoid code duplication.
60
+ """
61
+
62
+ def __init__(self, app: FunctualizeApp) -> None:
63
+ self._app = app
64
+ self._server: asyncio.Server | None = None
65
+
66
+ async def start(self, host: str, port: int) -> None:
67
+ """Start the HTTP server (async).
68
+
69
+ Blocks until the server is shut down via stop().
70
+
71
+ Args:
72
+ host: Host address to bind to.
73
+ port: Port number to listen on.
74
+ """
75
+ self._server = await asyncio.start_server(self._handle_connection, host, port)
76
+ addrs = ", ".join(str(s.getsockname()) for s in self._server.sockets)
77
+ logger.info(f"HTTP server listening on {addrs}")
78
+ async with self._server:
79
+ await self._server.serve_forever()
80
+
81
+ async def stop(self) -> None:
82
+ """Stop the HTTP server gracefully."""
83
+ if self._server is not None:
84
+ self._server.close()
85
+ await self._server.wait_closed()
86
+ self._server = None
87
+
88
+ def build_routes(self) -> dict[str, dict[str, Any]]:
89
+ """Build route map from registered jobs.
90
+
91
+ Returns a dict mapping (method, path) tuples conceptually:
92
+ - GET /health
93
+ - GET /jobs
94
+ - POST /jobs/{job_name}/execute for each job
95
+
96
+ This is used internally for request routing.
97
+ """
98
+ routes: dict[str, dict[str, Any]] = {}
99
+ for descriptor in self._app.get_jobs():
100
+ route_path = f"/jobs/{descriptor.name}/execute"
101
+ routes[route_path] = {
102
+ "method": "POST",
103
+ "job_name": descriptor.name,
104
+ }
105
+ return routes
106
+
107
+ async def handle_request(
108
+ self, method: str, path: str, body: bytes
109
+ ) -> tuple[int, dict[str, Any]]:
110
+ """Route and handle a single HTTP request.
111
+
112
+ Args:
113
+ method: HTTP method (GET, POST, etc.)
114
+ path: Request path.
115
+ body: Request body bytes.
116
+
117
+ Returns:
118
+ Tuple of (status_code, response_dict).
119
+ """
120
+ # Health check
121
+ if method == "GET" and path == "/health":
122
+ return 200, {"status": "healthy"}
123
+
124
+ # List jobs
125
+ if method == "GET" and path == "/jobs":
126
+ jobs = self._app.get_jobs()
127
+ job_list = [
128
+ {
129
+ "name": d.name,
130
+ "group": d.group,
131
+ "docstring": d.docstring,
132
+ }
133
+ for d in jobs
134
+ ]
135
+ return 200, {"jobs": job_list}
136
+
137
+ # Execute job: POST /jobs/{job_name}/execute
138
+ if method == "POST" and path.startswith("/jobs/") and path.endswith("/execute"):
139
+ # Extract job name from path
140
+ parts = path.split("/")
141
+ # Expected: ["", "jobs", "<job_name>", "execute"]
142
+ if len(parts) == 4:
143
+ job_name = parts[2]
144
+ return await self._execute_job(job_name, body)
145
+
146
+ # Not found
147
+ return 404, {"error": "Not found", "path": path}
148
+
149
+ async def _execute_job(
150
+ self, job_name: str, body: bytes
151
+ ) -> tuple[int, dict[str, Any]]:
152
+ """Execute a job with kwargs from the request body.
153
+
154
+ Uses asyncio.to_thread() to bridge the synchronous kernel
155
+ execution into the async server context.
156
+ """
157
+ # Parse body
158
+ kwargs: dict[str, Any] = {}
159
+ if body:
160
+ try:
161
+ kwargs = json.loads(body)
162
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
163
+ return 400, {"error": f"Invalid JSON body: {e}"}
164
+
165
+ if not isinstance(kwargs, dict):
166
+ return 400, {"error": "Request body must be a JSON object"}
167
+
168
+ # Check job exists
169
+ job = self._app.get_job(job_name)
170
+ if job is None:
171
+ return 404, {"error": f"Job '{job_name}' not found"}
172
+
173
+ # Execute via asyncio.to_thread (async-to-sync bridge)
174
+ try:
175
+ result = await asyncio.to_thread(self._app.execute, job_name, **kwargs)
176
+ return 200, {
177
+ "status": result.status.value
178
+ if hasattr(result.status, "value")
179
+ else str(result.status),
180
+ "duration_ms": result.duration_ms,
181
+ "return_value": self._serialize_return_value(result.return_value),
182
+ }
183
+ except Exception as e:
184
+ logger.exception(f"Error executing job '{job_name}'")
185
+ return 500, {"error": str(e)}
186
+
187
+ @staticmethod
188
+ def _serialize_return_value(value: Any) -> Any:
189
+ """Attempt to serialize a return value to JSON-compatible form."""
190
+ if value is None:
191
+ return None
192
+ if isinstance(value, str | int | float | bool):
193
+ return value
194
+ if isinstance(value, list | tuple):
195
+ return [HttpServerCore._serialize_return_value(v) for v in value]
196
+ if isinstance(value, dict):
197
+ return {
198
+ str(k): HttpServerCore._serialize_return_value(v)
199
+ for k, v in value.items()
200
+ }
201
+ # Fall back to string representation
202
+ return str(value)
203
+
204
+ async def _handle_connection(
205
+ self,
206
+ reader: asyncio.StreamReader,
207
+ writer: asyncio.StreamWriter,
208
+ ) -> None:
209
+ """Handle a single TCP connection (HTTP/1.1 basic parsing)."""
210
+ try:
211
+ # Read request line
212
+ request_line = await reader.readline()
213
+ if not request_line:
214
+ writer.close()
215
+ await writer.wait_closed()
216
+ return
217
+
218
+ request_str = request_line.decode("utf-8", errors="replace").strip()
219
+ parts = request_str.split(" ")
220
+ if len(parts) < 2:
221
+ await self._send_response(writer, 400, {"error": "Bad request"})
222
+ return
223
+
224
+ method = parts[0].upper()
225
+ path = parts[1].split("?")[0] # Strip query params
226
+
227
+ # Read headers
228
+ content_length = 0
229
+ while True:
230
+ header_line = await reader.readline()
231
+ if header_line in (b"\r\n", b"\n", b""):
232
+ break
233
+ header_str = header_line.decode("utf-8", errors="replace").strip()
234
+ if header_str.lower().startswith("content-length:"):
235
+ with contextlib.suppress(ValueError):
236
+ content_length = int(header_str.split(":", 1)[1].strip())
237
+
238
+ # Read body
239
+ body = b""
240
+ if content_length > 0:
241
+ body = await reader.readexactly(content_length)
242
+
243
+ # Handle request
244
+ status_code, response_body = await self.handle_request(method, path, body)
245
+ await self._send_response(writer, status_code, response_body)
246
+
247
+ except (ConnectionResetError, asyncio.IncompleteReadError):
248
+ pass
249
+ except Exception:
250
+ logger.exception("Error handling HTTP connection")
251
+ with contextlib.suppress(Exception):
252
+ await self._send_response(
253
+ writer, 500, {"error": "Internal server error"}
254
+ )
255
+ finally:
256
+ try:
257
+ writer.close()
258
+ await writer.wait_closed()
259
+ except Exception:
260
+ pass
261
+
262
+ @staticmethod
263
+ async def _send_response(
264
+ writer: asyncio.StreamWriter,
265
+ status_code: int,
266
+ body: dict[str, Any],
267
+ ) -> None:
268
+ """Send an HTTP response with JSON body."""
269
+ status_messages = {
270
+ 200: "OK",
271
+ 400: "Bad Request",
272
+ 404: "Not Found",
273
+ 500: "Internal Server Error",
274
+ }
275
+ status_text = status_messages.get(status_code, "Unknown")
276
+ body_bytes = json.dumps(body).encode("utf-8")
277
+
278
+ response = (
279
+ f"HTTP/1.1 {status_code} {status_text}\r\n"
280
+ f"Content-Type: application/json\r\n"
281
+ f"Content-Length: {len(body_bytes)}\r\n"
282
+ f"Connection: close\r\n"
283
+ f"\r\n"
284
+ ).encode() + body_bytes
285
+
286
+ writer.write(response)
287
+ await writer.drain()
288
+
289
+
290
+ class HttpAdapter:
291
+ """HTTP delivery adapter — satisfies AdapterPlugin Protocol.
292
+
293
+ Starts an async HTTP server that blocks until shutdown, exposing
294
+ all registered jobs as HTTP endpoints.
295
+
296
+ The adapter owns the event loop internally via asyncio.run(),
297
+ keeping the kernel synchronous.
298
+
299
+ Usage:
300
+ app = FunctualizeApp("myapp", job_sources=...)
301
+ adapter = HttpAdapter()
302
+ adapter(app)
303
+ adapter.run(host="0.0.0.0", port=8000)
304
+ """
305
+
306
+ name: str = "functualize-http"
307
+ version: str = "1.0.0"
308
+ description: str = "HTTP delivery adapter plugin for functualize using asyncio"
309
+ adapter_type: str = "http"
310
+
311
+ def __init__(self) -> None:
312
+ self._app: FunctualizeApp | None = None
313
+ self._core: HttpServerCore | None = None
314
+
315
+ def __call__(self, app: FunctualizeApp) -> None:
316
+ """Setup phase — store app reference and create server core.
317
+
318
+ Args:
319
+ app: The FunctualizeApp kernel instance.
320
+ """
321
+ self._app = app
322
+ self._core = HttpServerCore(app)
323
+
324
+ def run(self, *args: Any, **kwargs: Any) -> Any:
325
+ """Start the HTTP server (blocking).
326
+
327
+ Accepts keyword arguments:
328
+ host: Host address to bind to (default: "0.0.0.0").
329
+ port: Port number to listen on (default: 8000).
330
+
331
+ The adapter creates and runs an asyncio event loop internally.
332
+ This method blocks until shutdown() is called or the server
333
+ is interrupted.
334
+ """
335
+ if self._core is None:
336
+ raise RuntimeError("HttpAdapter.run() called before __call__(app)")
337
+
338
+ host = kwargs.get("host", "0.0.0.0")
339
+ port = kwargs.get("port", 8000)
340
+
341
+ asyncio.run(self._core.start(host, port))
342
+
343
+ def shutdown(self) -> None:
344
+ """Graceful shutdown — stops the HTTP server."""
345
+ if self._core is not None and self._core._server is not None:
346
+ # Schedule the stop coroutine on the running loop
347
+ loop = self._get_running_loop()
348
+ if loop is not None and loop.is_running():
349
+ loop.call_soon_threadsafe(
350
+ lambda: asyncio.ensure_future(self._core.stop()) # type: ignore[union-attr]
351
+ )
352
+
353
+ @staticmethod
354
+ def _get_running_loop() -> AbstractEventLoop | None:
355
+ """Get the currently running event loop, if any."""
356
+ try:
357
+ return asyncio.get_running_loop()
358
+ except RuntimeError:
359
+ return None
360
+
361
+
362
+ class HttpServerPlugin:
363
+ """Capability plugin that registers a 'serve' CLI command.
364
+
365
+ This plugin registers a `serve` command via
366
+ `app.register_plugin_command()`. When invoked, the serve command
367
+ starts an HTTP server using the shared HttpServerCore.
368
+
369
+ Usage:
370
+ app = FunctualizeApp("myapp", ...)
371
+ plugin = HttpServerPlugin()
372
+ plugin(app)
373
+ # The 'serve' command is now available in the CLI
374
+
375
+ The plugin is NOT an adapter — it augments the CLI adapter with
376
+ an HTTP serving command.
377
+ """
378
+
379
+ name: str = "functualize-http-server"
380
+ version: str = "1.0.0"
381
+ description: str = "Registers a 'serve' command for HTTP serving"
382
+
383
+ def __init__(self) -> None:
384
+ self._app: FunctualizeApp | None = None
385
+ self._core: HttpServerCore | None = None
386
+
387
+ def __call__(self, app: FunctualizeApp) -> None:
388
+ """Register the 'serve' command on the app.
389
+
390
+ Args:
391
+ app: The FunctualizeApp kernel instance.
392
+ """
393
+ self._app = app
394
+ self._core = HttpServerCore(app)
395
+
396
+ app.register_plugin_command(
397
+ name="serve",
398
+ callback=self._serve_command,
399
+ help_text="Start an HTTP server exposing all jobs as endpoints",
400
+ )
401
+
402
+ def _serve_command(
403
+ self,
404
+ host: str = "0.0.0.0",
405
+ port: int = 8000,
406
+ ) -> None:
407
+ """Start the HTTP server (CLI command handler).
408
+
409
+ Args:
410
+ host: Host address to bind to.
411
+ port: Port number to listen on.
412
+ """
413
+ if self._core is None:
414
+ raise RuntimeError("HttpServerPlugin not initialized")
415
+ asyncio.run(self._core.start(host, port))
416
+
417
+
418
+ __all__ = [
419
+ "HttpAdapter",
420
+ "HttpServerCore",
421
+ "HttpServerPlugin",
422
+ "PluginMetadata",
423
+ ]
File without changes
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: functualize-http
3
+ Version: 0.1.0
4
+ Summary: HTTP delivery adapter plugin for functualize using asyncio
5
+ Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: functualize<1.0.0,>=0.1.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
17
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # functualize-http
21
+
22
+ > **Status: Published** — Independently installable from PyPI.
23
+
24
+ HTTP delivery adapter plugin for functualize using Python's stdlib asyncio.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install functualize-http
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### As an Adapter
35
+
36
+ ```python
37
+ from functualize.app import FunctualizeApp
38
+ from functualize_http import HttpAdapter
39
+
40
+ app = FunctualizeApp("myapp", job_sources=...)
41
+ adapter = HttpAdapter()
42
+ adapter(app)
43
+ adapter.run(host="0.0.0.0", port=8000)
44
+ ```
45
+
46
+ ### As a CLI Plugin
47
+
48
+ ```python
49
+ from functualize.app import FunctualizeApp
50
+ from functualize_http import HttpServerPlugin
51
+
52
+ app = FunctualizeApp("myapp", job_sources=...)
53
+ plugin = HttpServerPlugin()
54
+ plugin(app)
55
+ # The 'serve' command is now available in the CLI
56
+ ```
57
+
58
+ ## Endpoints
59
+
60
+ - `GET /health` — Health check
61
+ - `GET /jobs` — List available jobs
62
+ - `POST /jobs/{job_name}/execute` — Execute a job with JSON body as kwargs
@@ -0,0 +1,6 @@
1
+ functualize_http/__init__.py,sha256=v15kr5NP5GTO7S2vVsaW0LvPixkIoMyTliCQ3xzfhz4,14260
2
+ functualize_http/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ functualize_http-0.1.0.dist-info/METADATA,sha256=niFzZMgmCht4vqoDGRw40rDaZR4Bm1_SBLg6E94IBcw,1608
4
+ functualize_http-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ functualize_http-0.1.0.dist-info/entry_points.txt,sha256=yjU7i50VJJ3eyZS9C68CC6hptTp3IZmeeNYC8qfdeCQ,58
6
+ functualize_http-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [functualize.plugins]
2
+ http = functualize_http:HttpAdapter