functualize-lambda 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_lambda-0.1.0/.gitignore +101 -0
- functualize_lambda-0.1.0/PKG-INFO +66 -0
- functualize_lambda-0.1.0/README.md +47 -0
- functualize_lambda-0.1.0/examples/README.md +18 -0
- functualize_lambda-0.1.0/examples/lambda_handler/README.md +60 -0
- functualize_lambda-0.1.0/examples/lambda_handler/pyproject.toml +20 -0
- functualize_lambda-0.1.0/examples/lambda_handler/src/lambda_service/__init__.py +1 -0
- functualize_lambda-0.1.0/examples/lambda_handler/src/lambda_service/app.py +44 -0
- functualize_lambda-0.1.0/examples/lambda_handler/src/lambda_service/jobs.py +82 -0
- functualize_lambda-0.1.0/examples/lambda_handler/tests/test_lambda_jobs.py +84 -0
- functualize_lambda-0.1.0/pyproject.toml +40 -0
- functualize_lambda-0.1.0/src/functualize_lambda/__init__.py +171 -0
- functualize_lambda-0.1.0/src/functualize_lambda/py.typed +0 -0
- functualize_lambda-0.1.0/tests/__init__.py +0 -0
- functualize_lambda-0.1.0/tests/conftest.py +44 -0
- functualize_lambda-0.1.0/tests/test_plugin.py +98 -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,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: functualize-lambda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AWS Lambda adapter plugin for functualize - supports fat and thin Lambda deployment patterns
|
|
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-lambda
|
|
21
|
+
|
|
22
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
23
|
+
|
|
24
|
+
AWS Lambda adapter plugin for [functualize](https://github.com/raicing-ai/functualize).
|
|
25
|
+
|
|
26
|
+
Supports two deployment patterns:
|
|
27
|
+
|
|
28
|
+
1. **Fat Lambda** — Single Lambda function with internal routing via `event["job"]`
|
|
29
|
+
2. **Thin Lambda** — One Lambda per job via `make_handler(job_name)`
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install functualize-lambda
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Fat Lambda (internal routing)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from functualize.app import FunctualizeApp, JobSources
|
|
43
|
+
from functualize_lambda import LambdaAdapter
|
|
44
|
+
|
|
45
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy, rollback]))
|
|
46
|
+
adapter = LambdaAdapter()
|
|
47
|
+
adapter(app)
|
|
48
|
+
|
|
49
|
+
def handler(event, context):
|
|
50
|
+
return adapter.run(event, context)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Events should have the shape: `{"job": "job_name", "kwargs": {"key": "value"}}`
|
|
54
|
+
|
|
55
|
+
### Thin Lambda (per-job handler)
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from functualize.app import FunctualizeApp, JobSources
|
|
59
|
+
from functualize_lambda import LambdaAdapter
|
|
60
|
+
|
|
61
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy]))
|
|
62
|
+
adapter = LambdaAdapter()
|
|
63
|
+
adapter(app)
|
|
64
|
+
|
|
65
|
+
handler = adapter.make_handler("deploy")
|
|
66
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# functualize-lambda
|
|
2
|
+
|
|
3
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
4
|
+
|
|
5
|
+
AWS Lambda adapter plugin for [functualize](https://github.com/raicing-ai/functualize).
|
|
6
|
+
|
|
7
|
+
Supports two deployment patterns:
|
|
8
|
+
|
|
9
|
+
1. **Fat Lambda** — Single Lambda function with internal routing via `event["job"]`
|
|
10
|
+
2. **Thin Lambda** — One Lambda per job via `make_handler(job_name)`
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install functualize-lambda
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Fat Lambda (internal routing)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from functualize.app import FunctualizeApp, JobSources
|
|
24
|
+
from functualize_lambda import LambdaAdapter
|
|
25
|
+
|
|
26
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy, rollback]))
|
|
27
|
+
adapter = LambdaAdapter()
|
|
28
|
+
adapter(app)
|
|
29
|
+
|
|
30
|
+
def handler(event, context):
|
|
31
|
+
return adapter.run(event, context)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Events should have the shape: `{"job": "job_name", "kwargs": {"key": "value"}}`
|
|
35
|
+
|
|
36
|
+
### Thin Lambda (per-job handler)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from functualize.app import FunctualizeApp, JobSources
|
|
40
|
+
from functualize_lambda import LambdaAdapter
|
|
41
|
+
|
|
42
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy]))
|
|
43
|
+
adapter = LambdaAdapter()
|
|
44
|
+
adapter(app)
|
|
45
|
+
|
|
46
|
+
handler = adapter.make_handler("deploy")
|
|
47
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# functualize-lambda Examples
|
|
2
|
+
|
|
3
|
+
The AWS Lambda delivery adapter: run jobs serverless.
|
|
4
|
+
|
|
5
|
+
| Directory | Demonstrates |
|
|
6
|
+
|-----------|--------------|
|
|
7
|
+
| [`lambda_handler/`](lambda_handler/) | "Fat Lambda" (one function routing all jobs) and "thin Lambda" (one function per job) deployment patterns |
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd plugins/functualize-lambda/examples/lambda_handler
|
|
11
|
+
uv sync
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Tests:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv run pytest plugins/functualize-lambda/examples/ -v
|
|
18
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Lambda Handler — Project Example
|
|
2
|
+
|
|
3
|
+
A functualize project deployed as AWS Lambda functions using the `functualize-lambda` adapter. Demonstrates both "fat Lambda" (single function with routing) and "thin Lambda" (one function per job) patterns.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd plugins/functualize-lambda/examples/lambda_handler
|
|
9
|
+
uv sync
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Deployment Patterns
|
|
13
|
+
|
|
14
|
+
### Fat Lambda (Internal Routing)
|
|
15
|
+
|
|
16
|
+
Single Lambda function that routes to different jobs based on the event payload:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# handler.py
|
|
20
|
+
from lambda_service.app import fat_handler
|
|
21
|
+
|
|
22
|
+
def handler(event, context):
|
|
23
|
+
return fat_handler(event, context)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Event format: `{"job": "process_order", "kwargs": {"order_id": "ORD-123"}}`
|
|
27
|
+
|
|
28
|
+
### Thin Lambda (Per-Job)
|
|
29
|
+
|
|
30
|
+
One Lambda function per job — simpler IAM, clearer monitoring:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# handlers/process_order.py
|
|
34
|
+
from lambda_service.app import process_order_handler
|
|
35
|
+
|
|
36
|
+
handler = process_order_handler
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Testing Locally
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Test job functions directly
|
|
43
|
+
uv run pytest tests/ -v
|
|
44
|
+
|
|
45
|
+
# Simulate a Lambda invocation
|
|
46
|
+
uv run python -c "
|
|
47
|
+
from lambda_service.app import fat_handler
|
|
48
|
+
result = fat_handler({'job': 'process_order', 'kwargs': {'order_id': 'ORD-001', 'amount': 99.99}}, None)
|
|
49
|
+
print(result)
|
|
50
|
+
"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What This Demonstrates
|
|
54
|
+
|
|
55
|
+
- `FunctualizeApp` with `JobSources(functions=[...])` for explicit registration
|
|
56
|
+
- `LambdaAdapter` for AWS Lambda delivery
|
|
57
|
+
- Fat Lambda pattern with event routing
|
|
58
|
+
- Thin Lambda pattern with `make_handler()`
|
|
59
|
+
- `twelve_factor()` config preset (env vars only — no files in Lambda)
|
|
60
|
+
- Same jobs testable locally without AWS
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lambda-service"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Example: Functualize jobs deployed as AWS Lambda functions"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"functualize",
|
|
8
|
+
"functualize-lambda",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/lambda_service"]
|
|
17
|
+
|
|
18
|
+
[tool.uv.sources]
|
|
19
|
+
functualize = { path = "../../../..", editable = true }
|
|
20
|
+
functualize-lambda = { path = "../..", editable = true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lambda service example — functualize jobs deployed to AWS Lambda."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Lambda application wiring — configures FunctualizeApp with Lambda adapter.
|
|
2
|
+
|
|
3
|
+
Demonstrates two deployment patterns:
|
|
4
|
+
1. Fat Lambda — single handler routing via event["job"]
|
|
5
|
+
2. Thin Lambda — one handler per job via make_handler()
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functualize_lambda import LambdaAdapter
|
|
9
|
+
|
|
10
|
+
from functualize.app import FunctualizeApp, JobSources, twelve_factor
|
|
11
|
+
from lambda_service.jobs import process_order, send_notification
|
|
12
|
+
|
|
13
|
+
# Create the app with explicit function registration (no directory scanning).
|
|
14
|
+
# This minimizes cold start time in Lambda — no filesystem I/O at boot.
|
|
15
|
+
app = FunctualizeApp(
|
|
16
|
+
name="lambda-service",
|
|
17
|
+
job_sources=JobSources(functions=[process_order, send_notification]),
|
|
18
|
+
config_sources=twelve_factor(), # Env vars only — no config files in Lambda
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Create the Lambda adapter
|
|
22
|
+
adapter = LambdaAdapter()
|
|
23
|
+
adapter(app)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Fat Lambda handler — routes based on event["job"]
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fat_handler(event: dict, context) -> dict:
|
|
32
|
+
"""Single Lambda entry point with internal job routing.
|
|
33
|
+
|
|
34
|
+
Event format: {"job": "process_order", "kwargs": {"order_id": "ORD-123"}}
|
|
35
|
+
"""
|
|
36
|
+
return adapter.run(event, context)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Thin Lambda handlers — one per job
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
process_order_handler = adapter.make_handler("process_order")
|
|
44
|
+
send_notification_handler = adapter.make_handler("send_notification")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Job definitions for the Lambda service.
|
|
2
|
+
|
|
3
|
+
These jobs are registered explicitly via JobSources(functions=[...])
|
|
4
|
+
rather than directory scanning — common in Lambda where cold start
|
|
5
|
+
time matters and we want minimal import overhead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from functualize.job.context import RunContext
|
|
11
|
+
from functualize.job.decorators import job
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Job 1: Process Order
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProcessOrderConfig(BaseModel):
|
|
19
|
+
"""Configuration for order processing."""
|
|
20
|
+
|
|
21
|
+
order_id: str = Field(description="Order identifier")
|
|
22
|
+
amount: float = Field(ge=0, description="Order amount in USD")
|
|
23
|
+
priority: bool = Field(default=False, description="Priority processing flag")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@job(
|
|
27
|
+
extra_description="Process an incoming order and validate payment",
|
|
28
|
+
category="orders",
|
|
29
|
+
tags=["order", "payment"],
|
|
30
|
+
visibility="external",
|
|
31
|
+
)
|
|
32
|
+
def process_order(config: ProcessOrderConfig, rc: RunContext) -> dict:
|
|
33
|
+
"""Process an order — validate, charge, and confirm.
|
|
34
|
+
|
|
35
|
+
In production this would integrate with payment providers and
|
|
36
|
+
inventory systems. Here we simulate the happy path.
|
|
37
|
+
"""
|
|
38
|
+
rc.log(f"Processing order {config.order_id} (${config.amount:.2f})")
|
|
39
|
+
|
|
40
|
+
if config.priority:
|
|
41
|
+
rc.log("Priority processing enabled")
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"order_id": config.order_id,
|
|
45
|
+
"status": "processed",
|
|
46
|
+
"amount": config.amount,
|
|
47
|
+
"priority": config.priority,
|
|
48
|
+
"confirmation_code": f"CONF-{config.order_id[-4:]}",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Job 2: Send Notification
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NotificationConfig(BaseModel):
|
|
58
|
+
"""Configuration for sending notifications."""
|
|
59
|
+
|
|
60
|
+
recipient: str = Field(description="Email or user ID")
|
|
61
|
+
message: str = Field(description="Notification message body")
|
|
62
|
+
channel: str = Field(
|
|
63
|
+
default="email", description="Delivery channel: email, sms, push"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@job(
|
|
68
|
+
extra_description="Send a notification to a user via the specified channel",
|
|
69
|
+
category="messaging",
|
|
70
|
+
tags=["notification", "messaging"],
|
|
71
|
+
visibility="external",
|
|
72
|
+
)
|
|
73
|
+
def send_notification(config: NotificationConfig, rc: RunContext) -> dict:
|
|
74
|
+
"""Send a notification through the configured channel."""
|
|
75
|
+
rc.log(f"Sending {config.channel} to {config.recipient}")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"recipient": config.recipient,
|
|
79
|
+
"channel": config.channel,
|
|
80
|
+
"status": "sent",
|
|
81
|
+
"message_preview": config.message[:50],
|
|
82
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tests for Lambda service jobs — prove they work without AWS."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
8
|
+
|
|
9
|
+
from lambda_service.jobs import (
|
|
10
|
+
NotificationConfig,
|
|
11
|
+
ProcessOrderConfig,
|
|
12
|
+
process_order,
|
|
13
|
+
send_notification,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_rc():
|
|
18
|
+
"""Create a minimal mock RunContext for testing."""
|
|
19
|
+
rc = MagicMock()
|
|
20
|
+
rc.log = MagicMock()
|
|
21
|
+
return rc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestProcessOrder:
|
|
25
|
+
"""Tests for the process_order job."""
|
|
26
|
+
|
|
27
|
+
def test_processes_order_successfully(self):
|
|
28
|
+
rc = _make_rc()
|
|
29
|
+
config = ProcessOrderConfig(order_id="ORD-001", amount=49.99)
|
|
30
|
+
result = process_order(config, rc)
|
|
31
|
+
|
|
32
|
+
assert result["order_id"] == "ORD-001"
|
|
33
|
+
assert result["status"] == "processed"
|
|
34
|
+
assert result["amount"] == 49.99
|
|
35
|
+
assert result["confirmation_code"] == "CONF--001"
|
|
36
|
+
|
|
37
|
+
def test_priority_processing(self):
|
|
38
|
+
rc = _make_rc()
|
|
39
|
+
config = ProcessOrderConfig(order_id="ORD-VIP", amount=199.99, priority=True)
|
|
40
|
+
result = process_order(config, rc)
|
|
41
|
+
|
|
42
|
+
assert result["priority"] is True
|
|
43
|
+
log_calls = [str(call) for call in rc.log.call_args_list]
|
|
44
|
+
assert any("Priority" in call for call in log_calls)
|
|
45
|
+
|
|
46
|
+
def test_zero_amount_order(self):
|
|
47
|
+
rc = _make_rc()
|
|
48
|
+
config = ProcessOrderConfig(order_id="ORD-FREE", amount=0.0)
|
|
49
|
+
result = process_order(config, rc)
|
|
50
|
+
|
|
51
|
+
assert result["amount"] == 0.0
|
|
52
|
+
assert result["status"] == "processed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestSendNotification:
|
|
56
|
+
"""Tests for the send_notification job."""
|
|
57
|
+
|
|
58
|
+
def test_sends_email_notification(self):
|
|
59
|
+
rc = _make_rc()
|
|
60
|
+
config = NotificationConfig(
|
|
61
|
+
recipient="user@example.com", message="Your order is ready!"
|
|
62
|
+
)
|
|
63
|
+
result = send_notification(config, rc)
|
|
64
|
+
|
|
65
|
+
assert result["recipient"] == "user@example.com"
|
|
66
|
+
assert result["channel"] == "email"
|
|
67
|
+
assert result["status"] == "sent"
|
|
68
|
+
|
|
69
|
+
def test_sends_sms_notification(self):
|
|
70
|
+
rc = _make_rc()
|
|
71
|
+
config = NotificationConfig(
|
|
72
|
+
recipient="+1234567890", message="Delivery arriving", channel="sms"
|
|
73
|
+
)
|
|
74
|
+
result = send_notification(config, rc)
|
|
75
|
+
|
|
76
|
+
assert result["channel"] == "sms"
|
|
77
|
+
|
|
78
|
+
def test_message_preview_truncated(self):
|
|
79
|
+
rc = _make_rc()
|
|
80
|
+
long_message = "A" * 100
|
|
81
|
+
config = NotificationConfig(recipient="user@test.com", message=long_message)
|
|
82
|
+
result = send_notification(config, rc)
|
|
83
|
+
|
|
84
|
+
assert len(result["message_preview"]) == 50
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "functualize-lambda"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AWS Lambda adapter plugin for functualize - supports fat and thin Lambda deployment patterns"
|
|
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
|
+
lambda = "functualize_lambda:LambdaAdapter"
|
|
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_lambda"]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Functualize Lambda Adapter Plugin — AWS Lambda delivery for FunctualizeApp.
|
|
2
|
+
|
|
3
|
+
Implements the AdapterPlugin Protocol with adapter_type="lambda".
|
|
4
|
+
Supports two deployment patterns:
|
|
5
|
+
|
|
6
|
+
1. Fat Lambda (internal routing):
|
|
7
|
+
- Single Lambda function handling multiple jobs
|
|
8
|
+
- Event contains {"job": "job_name", "kwargs": {...}}
|
|
9
|
+
- Adapter routes to the correct job via app.execute()
|
|
10
|
+
|
|
11
|
+
2. Thin Lambda (per-job handler):
|
|
12
|
+
- One Lambda function per job, routing handled by infrastructure
|
|
13
|
+
- Use make_handler(job_name) to create a bound handler
|
|
14
|
+
|
|
15
|
+
Both patterns work with the static wiring fast path for <5ms cold start
|
|
16
|
+
when using JobSources(functions=[...]) with fully-explicit config.
|
|
17
|
+
|
|
18
|
+
Usage (fat Lambda):
|
|
19
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy, rollback]))
|
|
20
|
+
adapter = LambdaAdapter()
|
|
21
|
+
adapter(app)
|
|
22
|
+
|
|
23
|
+
def handler(event, context):
|
|
24
|
+
return adapter.run(event, context)
|
|
25
|
+
|
|
26
|
+
Usage (thin Lambda):
|
|
27
|
+
app = FunctualizeApp("my-app", job_sources=JobSources(functions=[deploy]))
|
|
28
|
+
adapter = LambdaAdapter()
|
|
29
|
+
adapter(app)
|
|
30
|
+
|
|
31
|
+
handler = adapter.make_handler("deploy")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from typing import TYPE_CHECKING, Any
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from functualize.app.core import FunctualizeApp
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LambdaAdapter:
|
|
44
|
+
"""AWS Lambda delivery adapter.
|
|
45
|
+
|
|
46
|
+
Satisfies the AdapterPlugin Protocol. Supports fat-Lambda (internal
|
|
47
|
+
routing via event["job"]) and thin-Lambda (per-job handler via
|
|
48
|
+
make_handler()) deployment patterns.
|
|
49
|
+
|
|
50
|
+
PluginMetadata attributes:
|
|
51
|
+
name: "functualize-lambda"
|
|
52
|
+
version: "1.0.0"
|
|
53
|
+
description: "AWS Lambda adapter supporting fat and thin Lambda patterns"
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str = "functualize-lambda"
|
|
57
|
+
version: str = "1.0.0"
|
|
58
|
+
description: str = "AWS Lambda adapter supporting fat and thin Lambda patterns"
|
|
59
|
+
adapter_type: str = "lambda"
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._app: FunctualizeApp | None = None
|
|
63
|
+
|
|
64
|
+
def __call__(self, app: FunctualizeApp) -> None:
|
|
65
|
+
"""Setup phase — store app reference.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
app: The FunctualizeApp kernel instance.
|
|
69
|
+
"""
|
|
70
|
+
self._app = app
|
|
71
|
+
|
|
72
|
+
def run(self, *args: Any, **kwargs: Any) -> Any:
|
|
73
|
+
"""Fat Lambda entrypoint — route event to the correct job.
|
|
74
|
+
|
|
75
|
+
Parses the event to determine which job to execute and with
|
|
76
|
+
what arguments, then delegates to the kernel's execute method.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
*args: Expected to be (event, context) where event is a dict
|
|
80
|
+
containing "job" (required) and "kwargs" (optional).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict with "statusCode" (200 or 500) and "body" (result or
|
|
84
|
+
error message).
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
RuntimeError: If run() is called before __call__(app).
|
|
88
|
+
"""
|
|
89
|
+
if self._app is None:
|
|
90
|
+
raise RuntimeError("LambdaAdapter.run() called before __call__(app)")
|
|
91
|
+
|
|
92
|
+
# Extract event and context from positional args
|
|
93
|
+
event: dict[str, Any] = args[0] if args else kwargs.get("event", {})
|
|
94
|
+
# context is available but not used by the adapter itself
|
|
95
|
+
# _context = args[1] if len(args) > 1 else kwargs.get("context")
|
|
96
|
+
|
|
97
|
+
return self._handle_event(event)
|
|
98
|
+
|
|
99
|
+
def make_handler(self, job_name: str) -> Callable[..., Any]:
|
|
100
|
+
"""Create a thin-Lambda handler bound to a specific job.
|
|
101
|
+
|
|
102
|
+
Returns a callable with the standard Lambda signature
|
|
103
|
+
(event, context) that always executes the specified job.
|
|
104
|
+
Event kwargs can still be provided via event.get("kwargs", {}).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
job_name: The name of the job this handler will execute.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A callable(event, context) -> dict suitable as a Lambda handler.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
RuntimeError: If make_handler() is called before __call__(app).
|
|
114
|
+
"""
|
|
115
|
+
if self._app is None:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
"LambdaAdapter.make_handler() called before __call__(app)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
app = self._app
|
|
121
|
+
|
|
122
|
+
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
|
|
123
|
+
"""Thin Lambda handler for job '{job_name}'."""
|
|
124
|
+
job_kwargs = event.get("kwargs", {})
|
|
125
|
+
try:
|
|
126
|
+
result = app.execute(job_name, **job_kwargs)
|
|
127
|
+
return {"statusCode": 200, "body": result.return_value}
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
return {"statusCode": 500, "body": str(exc)}
|
|
130
|
+
|
|
131
|
+
# Set a useful name for debugging/logging
|
|
132
|
+
handler.__name__ = f"lambda_handler_{job_name}"
|
|
133
|
+
handler.__qualname__ = (
|
|
134
|
+
f"LambdaAdapter.make_handler.<locals>.handler[{job_name}]"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return handler
|
|
138
|
+
|
|
139
|
+
def shutdown(self) -> None:
|
|
140
|
+
"""No-op shutdown. Lambda functions are stateless."""
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def _handle_event(self, event: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
"""Internal: parse event and execute the job.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
event: Lambda event dict with "job" and optional "kwargs".
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Dict with "statusCode" and "body".
|
|
151
|
+
"""
|
|
152
|
+
assert self._app is not None
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
job_name = event["job"]
|
|
156
|
+
except (KeyError, TypeError) as exc:
|
|
157
|
+
return {
|
|
158
|
+
"statusCode": 400,
|
|
159
|
+
"body": f"Missing required field 'job' in event: {exc}",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
job_kwargs = event.get("kwargs", {})
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = self._app.execute(job_name, **job_kwargs)
|
|
166
|
+
return {"statusCode": 200, "body": result.return_value}
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
return {"statusCode": 500, "body": str(exc)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["LambdaAdapter"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared fixtures for functualize-lambda 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 FakeJobResult:
|
|
13
|
+
status: str = "success"
|
|
14
|
+
return_value: Any = None
|
|
15
|
+
duration_ms: float = 10.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeApp:
|
|
19
|
+
"""Minimal FunctualizeApp fake for Lambda adapter tests."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
execute_results: dict[str, FakeJobResult] | None = None,
|
|
24
|
+
execute_error: Exception | None = None,
|
|
25
|
+
):
|
|
26
|
+
self._execute_results = execute_results or {}
|
|
27
|
+
self._execute_error = execute_error
|
|
28
|
+
|
|
29
|
+
def execute(self, job_name: str, **kwargs: Any) -> FakeJobResult:
|
|
30
|
+
if self._execute_error:
|
|
31
|
+
raise self._execute_error
|
|
32
|
+
if job_name in self._execute_results:
|
|
33
|
+
return self._execute_results[job_name]
|
|
34
|
+
return FakeJobResult(return_value=f"executed {job_name}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def fake_app() -> FakeApp:
|
|
39
|
+
return FakeApp()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def failing_app() -> FakeApp:
|
|
44
|
+
return FakeApp(execute_error=RuntimeError("deploy failed"))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Unit tests for functualize-lambda plugin.
|
|
2
|
+
|
|
3
|
+
Tests the Lambda adapter's fat-lambda routing, thin-lambda handler creation,
|
|
4
|
+
and error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from functualize_lambda import LambdaAdapter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestFatLambda:
|
|
14
|
+
"""Tests for fat-Lambda (internal routing via event['job'])."""
|
|
15
|
+
|
|
16
|
+
def test_routes_to_correct_job(self, fake_app):
|
|
17
|
+
adapter = LambdaAdapter()
|
|
18
|
+
adapter(fake_app)
|
|
19
|
+
result = adapter.run({"job": "deploy", "kwargs": {"env": "prod"}}, None)
|
|
20
|
+
assert result["statusCode"] == 200
|
|
21
|
+
assert result["body"] == "executed deploy"
|
|
22
|
+
|
|
23
|
+
def test_missing_job_field_returns_400(self, fake_app):
|
|
24
|
+
adapter = LambdaAdapter()
|
|
25
|
+
adapter(fake_app)
|
|
26
|
+
result = adapter.run({}, None)
|
|
27
|
+
assert result["statusCode"] == 400
|
|
28
|
+
assert "Missing required field" in result["body"]
|
|
29
|
+
|
|
30
|
+
def test_execution_error_returns_500(self, failing_app):
|
|
31
|
+
adapter = LambdaAdapter()
|
|
32
|
+
adapter(failing_app)
|
|
33
|
+
result = adapter.run({"job": "deploy"}, None)
|
|
34
|
+
assert result["statusCode"] == 500
|
|
35
|
+
assert "deploy failed" in result["body"]
|
|
36
|
+
|
|
37
|
+
def test_run_before_setup_raises(self):
|
|
38
|
+
adapter = LambdaAdapter()
|
|
39
|
+
with pytest.raises(RuntimeError, match="called before __call__"):
|
|
40
|
+
adapter.run({"job": "test"}, None)
|
|
41
|
+
|
|
42
|
+
def test_kwargs_default_to_empty(self, fake_app):
|
|
43
|
+
adapter = LambdaAdapter()
|
|
44
|
+
adapter(fake_app)
|
|
45
|
+
result = adapter.run({"job": "deploy"}, None)
|
|
46
|
+
assert result["statusCode"] == 200
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestThinLambda:
|
|
50
|
+
"""Tests for thin-Lambda (per-job handlers via make_handler)."""
|
|
51
|
+
|
|
52
|
+
def test_make_handler_creates_callable(self, fake_app):
|
|
53
|
+
adapter = LambdaAdapter()
|
|
54
|
+
adapter(fake_app)
|
|
55
|
+
handler = adapter.make_handler("deploy")
|
|
56
|
+
assert callable(handler)
|
|
57
|
+
|
|
58
|
+
def test_handler_executes_bound_job(self, fake_app):
|
|
59
|
+
adapter = LambdaAdapter()
|
|
60
|
+
adapter(fake_app)
|
|
61
|
+
handler = adapter.make_handler("deploy")
|
|
62
|
+
result = handler({"kwargs": {"env": "staging"}}, None)
|
|
63
|
+
assert result["statusCode"] == 200
|
|
64
|
+
assert result["body"] == "executed deploy"
|
|
65
|
+
|
|
66
|
+
def test_handler_error_returns_500(self, failing_app):
|
|
67
|
+
adapter = LambdaAdapter()
|
|
68
|
+
adapter(failing_app)
|
|
69
|
+
handler = adapter.make_handler("deploy")
|
|
70
|
+
result = handler({}, None)
|
|
71
|
+
assert result["statusCode"] == 500
|
|
72
|
+
assert "deploy failed" in result["body"]
|
|
73
|
+
|
|
74
|
+
def test_make_handler_before_setup_raises(self):
|
|
75
|
+
adapter = LambdaAdapter()
|
|
76
|
+
with pytest.raises(RuntimeError, match="called before __call__"):
|
|
77
|
+
adapter.make_handler("deploy")
|
|
78
|
+
|
|
79
|
+
def test_handler_has_descriptive_name(self, fake_app):
|
|
80
|
+
adapter = LambdaAdapter()
|
|
81
|
+
adapter(fake_app)
|
|
82
|
+
handler = adapter.make_handler("deploy")
|
|
83
|
+
assert "deploy" in handler.__name__
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestAdapterMetadata:
|
|
87
|
+
"""Tests for adapter metadata attributes."""
|
|
88
|
+
|
|
89
|
+
def test_has_required_attributes(self):
|
|
90
|
+
adapter = LambdaAdapter()
|
|
91
|
+
assert adapter.name == "functualize-lambda"
|
|
92
|
+
assert adapter.adapter_type == "lambda"
|
|
93
|
+
assert adapter.version
|
|
94
|
+
|
|
95
|
+
def test_shutdown_is_noop(self, fake_app):
|
|
96
|
+
adapter = LambdaAdapter()
|
|
97
|
+
adapter(fake_app)
|
|
98
|
+
adapter.shutdown() # Should not raise
|