functualize-http 0.1.0__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.
@@ -0,0 +1,101 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ *.whl
11
+
12
+ # Agents
13
+ .spec/archive/
14
+ .spec/features/
15
+ .spec/scrutiny-reports/
16
+ .spec/.agentic-coding
17
+ .spec/STATE.md
18
+ .spec/PROJECT.md
19
+ .spec/REQUIREMENTS.md
20
+ .spec/ROADMAP.md
21
+ .opencode/
22
+
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+
29
+ # Testing
30
+ .coverage
31
+ .pytest_cache/
32
+ htmlcov/
33
+ .hypothesis/
34
+ snapshot_report.html
35
+ _*_result*.txt
36
+ _debug.txt
37
+ _tui_debug.txt
38
+ _tui_eval_debug.txt
39
+
40
+ # IDE
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+ *.code-workspace
46
+
47
+ # Coding-agent tooling state (guards — these dirs are not part of the repo)
48
+ .kiro/
49
+ .moai/
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Environment / secrets
56
+ .env
57
+ .env.*
58
+ !.env.example
59
+
60
+ # Agent scratch space (test output, temp scripts)
61
+ tmp/
62
+
63
+ # Local-only files (not for the repo)
64
+ *.local.md
65
+ *.local.*
66
+
67
+ # Personal notes
68
+ HUMAN_NOTE.md
69
+
70
+ # Distribution
71
+ dist/
72
+
73
+ # Documentation site build output
74
+ site/
75
+
76
+ # uv
77
+ .python-version
78
+ .functualize/cache.json
79
+ .functualize_cache.json
80
+ .todos/
81
+ .sidecar/
82
+ .sidecar-agent
83
+ .sidecar-task
84
+ .sidecar-pr
85
+ .sidecar-start.sh
86
+ .sidecar-base
87
+ .td-root
88
+ .functualize/
89
+ .import_linter_cache/
90
+ .mypy_cache/
91
+ .pytest_cache/
92
+ .ruff_cache/
93
+
94
+ # OmO / OpenCode agent run-continuation scratch state
95
+ .omo/
96
+ .mcp.json
97
+ .agentsroom/handoff-transcript-*.txt
98
+ .agentsroom/handoff-summary-*.md
99
+
100
+ # Internal pre-release audit reports (contain session IDs / local infra notes)
101
+ .release/
@@ -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,43 @@
1
+ # functualize-http
2
+
3
+ > **Status: Published** — Independently installable from PyPI.
4
+
5
+ HTTP delivery adapter plugin for functualize using Python's stdlib asyncio.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install functualize-http
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### As an Adapter
16
+
17
+ ```python
18
+ from functualize.app import FunctualizeApp
19
+ from functualize_http import HttpAdapter
20
+
21
+ app = FunctualizeApp("myapp", job_sources=...)
22
+ adapter = HttpAdapter()
23
+ adapter(app)
24
+ adapter.run(host="0.0.0.0", port=8000)
25
+ ```
26
+
27
+ ### As a CLI Plugin
28
+
29
+ ```python
30
+ from functualize.app import FunctualizeApp
31
+ from functualize_http import HttpServerPlugin
32
+
33
+ app = FunctualizeApp("myapp", job_sources=...)
34
+ plugin = HttpServerPlugin()
35
+ plugin(app)
36
+ # The 'serve' command is now available in the CLI
37
+ ```
38
+
39
+ ## Endpoints
40
+
41
+ - `GET /health` — Health check
42
+ - `GET /jobs` — List available jobs
43
+ - `POST /jobs/{job_name}/execute` — Execute a job with JSON body as kwargs
@@ -0,0 +1,22 @@
1
+ # functualize-http Examples
2
+
3
+ The HTTP delivery adapter: expose the same jobs as API endpoints and CLI commands.
4
+
5
+ | Directory | Demonstrates |
6
+ |-----------|--------------|
7
+ | [`http_service/`](http_service/) | A full project serving jobs over HTTP via `HttpAdapter`, with the same jobs runnable via CLI |
8
+
9
+ ```bash
10
+ cd plugins/functualize-http/examples/http_service
11
+ uv sync
12
+ uv run python -m http_service # starts on :8000
13
+
14
+ # Same jobs via CLI
15
+ uv run http-service healthcheck run --service-url https://example.com
16
+ ```
17
+
18
+ Tests:
19
+
20
+ ```bash
21
+ uv run pytest plugins/functualize-http/examples/ -v
22
+ ```
@@ -0,0 +1,45 @@
1
+ # HTTP Service — Project Example
2
+
3
+ A functualize project deployed as an HTTP API using the `functualize-http` adapter. Same jobs, different delivery surface — no code changes required.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ cd plugins/functualize-http/examples/http_service
9
+ uv sync
10
+ ```
11
+
12
+ ## Running
13
+
14
+ ```bash
15
+ # Start the HTTP server
16
+ uv run python -m http_service
17
+
18
+ # Or use the CLI adapter (same jobs)
19
+ uv run http-service --help
20
+ ```
21
+
22
+ ## Endpoints
23
+
24
+ Once the server is running:
25
+
26
+ ```bash
27
+ # Health check
28
+ curl http://localhost:8000/health
29
+
30
+ # List available jobs
31
+ curl http://localhost:8000/jobs
32
+
33
+ # Execute a job
34
+ curl -X POST http://localhost:8000/jobs/healthcheck/execute \
35
+ -H "Content-Type: application/json" \
36
+ -d '{"service_url": "https://api.example.com", "timeout": 5}'
37
+ ```
38
+
39
+ ## What This Demonstrates
40
+
41
+ - `FunctualizeApp` with `JobSources` and `twelve_factor()` config preset
42
+ - `HttpAdapter` for HTTP API delivery
43
+ - Same jobs work via CLI and HTTP without modification
44
+ - Pydantic config models become JSON request schemas
45
+ - `@job_metadata` tags control which jobs are exposed
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "http-service"
3
+ version = "0.1.0"
4
+ description = "Example: Functualize jobs exposed as an HTTP API"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "functualize",
8
+ "functualize-http",
9
+ "functualize-state",
10
+ ]
11
+
12
+ [project.scripts]
13
+ http-service = "http_service.app:run_cli"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/http_service"]
21
+
22
+ [tool.uv.sources]
23
+ functualize = { path = "../../../..", editable = true }
24
+ functualize-http = { path = "../..", editable = true }
25
+ functualize-state = { path = "../../../functualize-state", editable = true }
@@ -0,0 +1 @@
1
+ """HTTP Service example — functualize jobs delivered via HTTP API."""
@@ -0,0 +1,6 @@
1
+ """Allow running the package directly: python -m http_service"""
2
+
3
+ from http_service.app import run_http
4
+
5
+ if __name__ == "__main__":
6
+ run_http()
@@ -0,0 +1,36 @@
1
+ """Application entry point — configures FunctualizeApp with HTTP adapter.
2
+
3
+ This module wires together job discovery, configuration, and the HTTP
4
+ delivery adapter. The same jobs are accessible via both CLI and HTTP.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from functualize_http import HttpAdapter
10
+
11
+ from functualize.app import FunctualizeApp, JobSources, twelve_factor
12
+
13
+ JOBS_DIR = str(Path(__file__).parent / "jobs")
14
+
15
+ # Create the app with twelve-factor config (env vars, no files)
16
+ app = FunctualizeApp(
17
+ name="http-service",
18
+ job_sources=JobSources(directories=[JOBS_DIR]),
19
+ config_sources=twelve_factor(dotenv=True),
20
+ )
21
+
22
+
23
+ def run_http(host: str = "0.0.0.0", port: int = 8000) -> None:
24
+ """Start the HTTP server."""
25
+ adapter = HttpAdapter()
26
+ adapter(app)
27
+ adapter.run(host=host, port=port)
28
+
29
+
30
+ def run_cli() -> None:
31
+ """Start the CLI interface (same jobs, different delivery)."""
32
+ app.run()
33
+
34
+
35
+ if __name__ == "__main__":
36
+ run_http()
@@ -0,0 +1 @@
1
+ """Jobs for the HTTP service example."""
@@ -0,0 +1,64 @@
1
+ """Deploy job — deploy application artifacts to an environment."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from functualize.job.context import RunContext
8
+ from functualize.job.decorators import job
9
+
10
+ JOB_NAME = "deploy"
11
+
12
+
13
+ class Environment(StrEnum):
14
+ """Target deployment environment."""
15
+
16
+ staging = "staging"
17
+ production = "production"
18
+
19
+
20
+ class DeployConfig(BaseModel):
21
+ """Configuration for the deploy job."""
22
+
23
+ version: str = Field(description="Version tag to deploy (e.g., v1.2.3)")
24
+ environment: Environment = Field(
25
+ default=Environment.staging, description="Target environment"
26
+ )
27
+ dry_run: bool = Field(default=False, description="Preview without applying changes")
28
+
29
+
30
+ @job(
31
+ extra_description="Deploy application artifacts to staging or production",
32
+ category="deployment",
33
+ tags=["deploy", "infrastructure"],
34
+ examples=["deploy --version v1.2.3 --environment production"],
35
+ visibility="external",
36
+ )
37
+ def run(config: DeployConfig, rc: RunContext) -> dict:
38
+ """Deploy the application to the specified environment.
39
+
40
+ Builds a container image, pushes to the registry, and updates
41
+ the target environment's deployment manifest.
42
+ """
43
+ rc.log(f"Deploying {config.version} to {config.environment.value}")
44
+
45
+ if config.dry_run:
46
+ rc.log("DRY RUN — no changes applied")
47
+ return {
48
+ "version": config.version,
49
+ "environment": config.environment.value,
50
+ "status": "dry_run",
51
+ "changes_applied": False,
52
+ }
53
+
54
+ # Simulated deployment steps
55
+ rc.log("Building container image...")
56
+ rc.log("Pushing to registry...")
57
+ rc.log("Updating deployment manifest...")
58
+
59
+ return {
60
+ "version": config.version,
61
+ "environment": config.environment.value,
62
+ "status": "deployed",
63
+ "changes_applied": True,
64
+ }
@@ -0,0 +1,45 @@
1
+ """Health check job — verify a service is responding."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from functualize.job.context import RunContext
6
+ from functualize.job.decorators import job
7
+
8
+ JOB_NAME = "healthcheck"
9
+
10
+
11
+ class HealthcheckConfig(BaseModel):
12
+ """Configuration for the healthcheck job."""
13
+
14
+ service_url: str = Field(description="URL of the service to check")
15
+ timeout: int = Field(
16
+ default=5, ge=1, le=30, description="Request timeout in seconds"
17
+ )
18
+ expected_status: int = Field(default=200, description="Expected HTTP status code")
19
+
20
+
21
+ @job(
22
+ extra_description="Check if a service endpoint is healthy and responding",
23
+ category="monitoring",
24
+ tags=["health", "monitoring", "safe", "read-only"],
25
+ visibility="external",
26
+ )
27
+ def run(config: HealthcheckConfig, rc: RunContext) -> dict:
28
+ """Check service health by making a request to the configured URL.
29
+
30
+ Returns status information including response time and status code.
31
+ This is a read-only operation suitable for automated monitoring.
32
+ """
33
+ rc.log(f"Checking health: {config.service_url}")
34
+
35
+ # Simulated health check (real impl would use httpx/urllib)
36
+ result = {
37
+ "url": config.service_url,
38
+ "status": "healthy",
39
+ "status_code": config.expected_status,
40
+ "response_time_ms": 42,
41
+ "timeout_configured": config.timeout,
42
+ }
43
+
44
+ rc.log(f"Service healthy: {result['response_time_ms']}ms response time")
45
+ return result
@@ -0,0 +1,88 @@
1
+ """Tests for HTTP service jobs — prove they work without the HTTP adapter."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ # Add the src directory so we can import the jobs directly
8
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
9
+
10
+ from http_service.jobs.deploy import DeployConfig, Environment
11
+ from http_service.jobs.deploy import run as deploy_run
12
+ from http_service.jobs.healthcheck import HealthcheckConfig
13
+ from http_service.jobs.healthcheck import run as healthcheck_run
14
+
15
+
16
+ def _make_rc():
17
+ """Create a minimal mock RunContext for testing."""
18
+ rc = MagicMock()
19
+ rc.log = MagicMock()
20
+ return rc
21
+
22
+
23
+ class TestHealthcheck:
24
+ """Tests for the healthcheck job."""
25
+
26
+ def test_returns_healthy_status(self):
27
+ rc = _make_rc()
28
+ config = HealthcheckConfig(service_url="https://api.example.com")
29
+ result = healthcheck_run(config, rc)
30
+
31
+ assert result["status"] == "healthy"
32
+ assert result["url"] == "https://api.example.com"
33
+ assert result["status_code"] == 200
34
+
35
+ def test_respects_timeout_config(self):
36
+ rc = _make_rc()
37
+ config = HealthcheckConfig(service_url="https://slow.example.com", timeout=10)
38
+ result = healthcheck_run(config, rc)
39
+
40
+ assert result["timeout_configured"] == 10
41
+
42
+ def test_custom_expected_status(self):
43
+ rc = _make_rc()
44
+ config = HealthcheckConfig(
45
+ service_url="https://api.example.com", expected_status=204
46
+ )
47
+ result = healthcheck_run(config, rc)
48
+
49
+ assert result["status_code"] == 204
50
+
51
+
52
+ class TestDeploy:
53
+ """Tests for the deploy job."""
54
+
55
+ def test_deploy_to_staging(self):
56
+ rc = _make_rc()
57
+ config = DeployConfig(version="v1.0.0", environment=Environment.staging)
58
+ result = deploy_run(config, rc)
59
+
60
+ assert result["version"] == "v1.0.0"
61
+ assert result["environment"] == "staging"
62
+ assert result["status"] == "deployed"
63
+ assert result["changes_applied"] is True
64
+
65
+ def test_deploy_dry_run(self):
66
+ rc = _make_rc()
67
+ config = DeployConfig(version="v2.0.0", dry_run=True)
68
+ result = deploy_run(config, rc)
69
+
70
+ assert result["status"] == "dry_run"
71
+ assert result["changes_applied"] is False
72
+
73
+ def test_deploy_to_production(self):
74
+ rc = _make_rc()
75
+ config = DeployConfig(version="v1.5.0", environment=Environment.production)
76
+ result = deploy_run(config, rc)
77
+
78
+ assert result["environment"] == "production"
79
+ assert result["changes_applied"] is True
80
+
81
+ def test_deploy_logs_steps(self):
82
+ rc = _make_rc()
83
+ config = DeployConfig(version="v3.0.0")
84
+ deploy_run(config, rc)
85
+
86
+ log_calls = [str(call) for call in rc.log.call_args_list]
87
+ assert any("Deploying" in call for call in log_calls)
88
+ assert any("container image" in call for call in log_calls)
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "functualize-http"
3
+ version = "0.1.0"
4
+ description = "HTTP delivery adapter plugin for functualize using asyncio"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "functualize>=0.1.0,<1.0.0",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.entry-points."functualize.plugins"]
24
+ http = "functualize_http:HttpAdapter"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=7.4.0",
29
+ "pytest-cov>=4.1.0",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.uv.sources]
37
+ functualize = { workspace = true }
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/functualize_http"]
@@ -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
File without changes
@@ -0,0 +1,73 @@
1
+ """Shared fixtures for functualize-http plugin tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import pytest
9
+
10
+
11
+ @dataclass
12
+ class FakeDescriptor:
13
+ name: str
14
+ group: str | None = None
15
+ docstring: str | None = None
16
+
17
+
18
+ @dataclass
19
+ class FakeJobResult:
20
+ status: str = "success"
21
+ return_value: Any = None
22
+ duration_ms: float = 5.0
23
+
24
+
25
+ class FakeApp:
26
+ """Minimal FunctualizeApp fake for HTTP adapter tests."""
27
+
28
+ def __init__(
29
+ self,
30
+ descriptors: list[FakeDescriptor] | None = None,
31
+ execute_results: dict[str, FakeJobResult] | None = None,
32
+ execute_error: Exception | None = None,
33
+ ):
34
+ self._descriptors = descriptors or []
35
+ self._execute_results = execute_results or {}
36
+ self._execute_error = execute_error
37
+ self._commands: dict[str, Any] = {}
38
+
39
+ def get_jobs(self) -> list[FakeDescriptor]:
40
+ return self._descriptors
41
+
42
+ def get_job(self, name: str) -> FakeDescriptor | None:
43
+ for d in self._descriptors:
44
+ if d.name == name:
45
+ return d
46
+ return None
47
+
48
+ def execute(self, job_name: str, **kwargs: Any) -> FakeJobResult:
49
+ if self._execute_error:
50
+ raise self._execute_error
51
+ if job_name in self._execute_results:
52
+ return self._execute_results[job_name]
53
+ return FakeJobResult(return_value=f"executed {job_name}")
54
+
55
+ def register_plugin_command(
56
+ self, name: str, callback: Any, help_text: str = ""
57
+ ) -> None:
58
+ self._commands[name] = callback
59
+
60
+
61
+ @pytest.fixture
62
+ def fake_app() -> FakeApp:
63
+ return FakeApp(
64
+ descriptors=[
65
+ FakeDescriptor(name="greet", docstring="Greet user"),
66
+ FakeDescriptor(name="deploy", group="ops", docstring="Deploy"),
67
+ ]
68
+ )
69
+
70
+
71
+ @pytest.fixture
72
+ def empty_app() -> FakeApp:
73
+ return FakeApp(descriptors=[])
@@ -0,0 +1,144 @@
1
+ """Unit tests for functualize-http plugin.
2
+
3
+ Tests the HTTP server core logic: request routing, job execution,
4
+ health endpoints, and error handling. Uses direct method calls on
5
+ HttpServerCore.handle_request() to avoid needing a running server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+
13
+ import pytest
14
+ from functualize_http import HttpAdapter, HttpServerCore, HttpServerPlugin
15
+
16
+ from .conftest import FakeApp, FakeDescriptor
17
+
18
+
19
+ class TestHealthEndpoint:
20
+ """Tests for GET /health."""
21
+
22
+ def test_health_returns_200(self, fake_app):
23
+ core = HttpServerCore(fake_app)
24
+ status, body = asyncio.run(core.handle_request("GET", "/health", b""))
25
+ assert status == 200
26
+ assert body == {"status": "healthy"}
27
+
28
+
29
+ class TestJobListing:
30
+ """Tests for GET /jobs."""
31
+
32
+ def test_lists_all_jobs(self, fake_app):
33
+ core = HttpServerCore(fake_app)
34
+ status, body = asyncio.run(core.handle_request("GET", "/jobs", b""))
35
+ assert status == 200
36
+ assert len(body["jobs"]) == 2
37
+ names = [j["name"] for j in body["jobs"]]
38
+ assert "greet" in names
39
+ assert "deploy" in names
40
+
41
+ def test_empty_app_returns_empty_list(self, empty_app):
42
+ core = HttpServerCore(empty_app)
43
+ status, body = asyncio.run(core.handle_request("GET", "/jobs", b""))
44
+ assert status == 200
45
+ assert body == {"jobs": []}
46
+
47
+
48
+ class TestJobExecution:
49
+ """Tests for POST /jobs/{name}/execute."""
50
+
51
+ def test_execute_job_success(self, fake_app):
52
+ core = HttpServerCore(fake_app)
53
+ payload = json.dumps({"name": "World"}).encode()
54
+ status, body = asyncio.run(
55
+ core.handle_request("POST", "/jobs/greet/execute", payload)
56
+ )
57
+ assert status == 200
58
+ assert body["return_value"] == "executed greet"
59
+
60
+ def test_execute_nonexistent_job_returns_404(self, fake_app):
61
+ core = HttpServerCore(fake_app)
62
+ status, body = asyncio.run(
63
+ core.handle_request("POST", "/jobs/nope/execute", b"")
64
+ )
65
+ assert status == 404
66
+ assert "not found" in body["error"].lower()
67
+
68
+ def test_execute_with_invalid_json_returns_400(self, fake_app):
69
+ core = HttpServerCore(fake_app)
70
+ status, body = asyncio.run(
71
+ core.handle_request("POST", "/jobs/greet/execute", b"not json")
72
+ )
73
+ assert status == 400
74
+ assert "Invalid JSON" in body["error"]
75
+
76
+ def test_execute_with_non_object_body_returns_400(self, fake_app):
77
+ core = HttpServerCore(fake_app)
78
+ payload = json.dumps([1, 2, 3]).encode()
79
+ status, body = asyncio.run(
80
+ core.handle_request("POST", "/jobs/greet/execute", payload)
81
+ )
82
+ assert status == 400
83
+ assert "must be a JSON object" in body["error"]
84
+
85
+ def test_execute_with_empty_body_succeeds(self, fake_app):
86
+ core = HttpServerCore(fake_app)
87
+ status, body = asyncio.run(
88
+ core.handle_request("POST", "/jobs/greet/execute", b"")
89
+ )
90
+ assert status == 200
91
+
92
+ def test_execution_error_returns_500(self):
93
+ app = FakeApp(
94
+ descriptors=[FakeDescriptor(name="broken", docstring="Broken")],
95
+ execute_error=RuntimeError("kaboom"),
96
+ )
97
+ core = HttpServerCore(app)
98
+ status, body = asyncio.run(
99
+ core.handle_request("POST", "/jobs/broken/execute", b"")
100
+ )
101
+ assert status == 500
102
+ assert "kaboom" in body["error"]
103
+
104
+
105
+ class TestRouting:
106
+ """Tests for route matching."""
107
+
108
+ def test_unknown_path_returns_404(self, fake_app):
109
+ core = HttpServerCore(fake_app)
110
+ status, body = asyncio.run(core.handle_request("GET", "/unknown/path", b""))
111
+ assert status == 404
112
+
113
+ def test_build_routes_maps_jobs(self, fake_app):
114
+ core = HttpServerCore(fake_app)
115
+ routes = core.build_routes()
116
+ assert "/jobs/greet/execute" in routes
117
+ assert "/jobs/deploy/execute" in routes
118
+
119
+
120
+ class TestHttpAdapter:
121
+ """Tests for HttpAdapter setup and metadata."""
122
+
123
+ def test_adapter_type(self):
124
+ adapter = HttpAdapter()
125
+ assert adapter.adapter_type == "http"
126
+
127
+ def test_run_before_setup_raises(self):
128
+ adapter = HttpAdapter()
129
+ with pytest.raises(RuntimeError, match="called before __call__"):
130
+ adapter.run()
131
+
132
+ def test_setup_creates_core(self, fake_app):
133
+ adapter = HttpAdapter()
134
+ adapter(fake_app)
135
+ assert adapter._core is not None
136
+
137
+
138
+ class TestHttpServerPlugin:
139
+ """Tests for HttpServerPlugin command registration."""
140
+
141
+ def test_registers_serve_command(self, fake_app):
142
+ plugin = HttpServerPlugin()
143
+ plugin(fake_app)
144
+ assert "serve" in fake_app._commands