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.
- functualize_http-0.1.0/.gitignore +101 -0
- functualize_http-0.1.0/PKG-INFO +62 -0
- functualize_http-0.1.0/README.md +43 -0
- functualize_http-0.1.0/examples/README.md +22 -0
- functualize_http-0.1.0/examples/http_service/README.md +45 -0
- functualize_http-0.1.0/examples/http_service/pyproject.toml +25 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/__init__.py +1 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/__main__.py +6 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/app.py +36 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/jobs/__init__.py +1 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/jobs/deploy.py +64 -0
- functualize_http-0.1.0/examples/http_service/src/http_service/jobs/healthcheck.py +45 -0
- functualize_http-0.1.0/examples/http_service/tests/test_http_jobs.py +88 -0
- functualize_http-0.1.0/pyproject.toml +40 -0
- functualize_http-0.1.0/src/functualize_http/__init__.py +423 -0
- functualize_http-0.1.0/src/functualize_http/py.typed +0 -0
- functualize_http-0.1.0/tests/__init__.py +0 -0
- functualize_http-0.1.0/tests/conftest.py +73 -0
- functualize_http-0.1.0/tests/test_plugin.py +144 -0
|
@@ -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,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
|