lambda-forge-cli-aws 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lambda_forge_cli_aws-1.0.0.dist-info/METADATA +352 -0
- lambda_forge_cli_aws-1.0.0.dist-info/RECORD +80 -0
- lambda_forge_cli_aws-1.0.0.dist-info/WHEEL +4 -0
- lambda_forge_cli_aws-1.0.0.dist-info/entry_points.txt +2 -0
- lambda_forge_cli_aws-1.0.0.dist-info/licenses/LICENSE +189 -0
- lambdaforge/__init__.py +10 -0
- lambdaforge/bedrock_client.py +109 -0
- lambdaforge/cli.py +428 -0
- lambdaforge/deploy.py +308 -0
- lambdaforge/logs.py +185 -0
- lambdaforge/renderer.py +72 -0
- lambdaforge/spec_generator.py +171 -0
- lambdaforge/status.py +111 -0
- lambdaforge/templates.py +106 -0
- lambdaforge/upgrade.py +231 -0
- templates/python-api/cookiecutter.json +12 -0
- templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/dependabot.yml +22 -0
- templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/deploy.yml +90 -0
- templates/python-api/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-api/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-api/{{cookiecutter.project_slug}}/README.md +130 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/app.js +26 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/cdk.json +20 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +155 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-api/{{cookiecutter.project_slug}}/pyproject.toml +66 -0
- templates/python-api/{{cookiecutter.project_slug}}/requirements-dev.txt +8 -0
- templates/python-api/{{cookiecutter.project_slug}}/requirements.txt +3 -0
- templates/python-api/{{cookiecutter.project_slug}}/src/handler.py +93 -0
- templates/python-api/{{cookiecutter.project_slug}}/src/utils/logger.py +39 -0
- templates/python-api/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-api/{{cookiecutter.project_slug}}/tests/test_handler.py +68 -0
- templates/python-cron/cookiecutter.json +13 -0
- templates/python-cron/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-cron/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +55 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-cron/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-cron/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-cron/{{cookiecutter.project_slug}}/src/handler.py +53 -0
- templates/python-cron/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-cron/{{cookiecutter.project_slug}}/tests/test_handler.py +48 -0
- templates/python-queue/cookiecutter.json +12 -0
- templates/python-queue/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-queue/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +79 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-queue/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-queue/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-queue/{{cookiecutter.project_slug}}/src/handler.py +64 -0
- templates/python-queue/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-queue/{{cookiecutter.project_slug}}/tests/test_handler.py +60 -0
- templates/python-s3/cookiecutter.json +13 -0
- templates/python-s3/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-s3/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +89 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-s3/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-s3/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-s3/{{cookiecutter.project_slug}}/src/handler.py +86 -0
- templates/python-s3/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-s3/{{cookiecutter.project_slug}}/tests/test_handler.py +71 -0
- templates/python-stream/cookiecutter.json +12 -0
- templates/python-stream/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-stream/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +81 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-stream/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-stream/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-stream/{{cookiecutter.project_slug}}/src/handler.py +69 -0
- templates/python-stream/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-stream/{{cookiecutter.project_slug}}/tests/test_handler.py +64 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Test configuration and fixtures."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def aws_credentials(monkeypatch):
|
|
11
|
+
"""Mock AWS credentials for moto."""
|
|
12
|
+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
|
13
|
+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
|
14
|
+
monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing")
|
|
15
|
+
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
|
|
16
|
+
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def table_name():
|
|
21
|
+
"""Provide a test table name."""
|
|
22
|
+
return os.environ.get("TABLE_NAME", "test-table")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tests for {{ cookiecutter.project_name }} S3 event processor handler."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from src.handler import handler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def s3_put_event():
|
|
13
|
+
"""Sample S3 PutObject event."""
|
|
14
|
+
return {
|
|
15
|
+
"Records": [
|
|
16
|
+
{
|
|
17
|
+
"eventVersion": "2.1",
|
|
18
|
+
"eventSource": "aws:s3",
|
|
19
|
+
"awsRegion": "us-east-1",
|
|
20
|
+
"eventTime": "2024-01-01T00:00:00.000Z",
|
|
21
|
+
"eventName": "ObjectCreated:Put",
|
|
22
|
+
"s3": {
|
|
23
|
+
"s3SchemaVersion": "1.0",
|
|
24
|
+
"configurationId": "test-config",
|
|
25
|
+
"bucket": {
|
|
26
|
+
"name": "test-bucket",
|
|
27
|
+
"arn": "arn:aws:s3:::test-bucket",
|
|
28
|
+
},
|
|
29
|
+
"object": {
|
|
30
|
+
"key": "uploads/test-file.csv",
|
|
31
|
+
"size": 1024,
|
|
32
|
+
"eTag": "test-etag",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_handler_processes_s3_event(s3_put_event):
|
|
41
|
+
"""Test handler processes S3 PutObject event without error."""
|
|
42
|
+
context = MagicMock()
|
|
43
|
+
context.function_name = "test-function"
|
|
44
|
+
context.aws_request_id = "test-request-id"
|
|
45
|
+
|
|
46
|
+
mock_response = {
|
|
47
|
+
"ContentLength": 1024,
|
|
48
|
+
"ContentType": "text/csv",
|
|
49
|
+
"Body": MagicMock(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
with patch("src.handler.s3_client.get_object", return_value=mock_response), \
|
|
53
|
+
patch("src.handler.dynamodb"):
|
|
54
|
+
response = handler(s3_put_event, context)
|
|
55
|
+
|
|
56
|
+
assert response["statusCode"] == 200
|
|
57
|
+
assert response["body"]["processed"] == 1
|
|
58
|
+
assert response["body"]["failed"] == 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_handler_handles_empty_records():
|
|
62
|
+
"""Test handler handles event with no records."""
|
|
63
|
+
event = {"Records": []}
|
|
64
|
+
context = MagicMock()
|
|
65
|
+
context.function_name = "test-function"
|
|
66
|
+
context.aws_request_id = "test-request-id"
|
|
67
|
+
|
|
68
|
+
response = handler(event, context)
|
|
69
|
+
|
|
70
|
+
assert response["statusCode"] == 200
|
|
71
|
+
assert response["body"]["processed"] == 0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project_name": "my-stream-processor",
|
|
3
|
+
"project_slug": "{{ cookiecutter.project_name|replace('-', '_') }}",
|
|
4
|
+
"module_name": "{{ cookiecutter.project_slug }}",
|
|
5
|
+
"description": "Kinesis/DynamoDB Streams processor Lambda",
|
|
6
|
+
"author_name": "LambdaForge User",
|
|
7
|
+
"author_email": "user@example.com",
|
|
8
|
+
"runtime": "python3.12",
|
|
9
|
+
"aws_region": "us-east-1",
|
|
10
|
+
"memory_mb": 1024,
|
|
11
|
+
"timeout_seconds": 60
|
|
12
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
MANIFEST
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
env/
|
|
27
|
+
ENV/
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
htmlcov/
|
|
34
|
+
.tox/
|
|
35
|
+
.nox/
|
|
36
|
+
coverage.xml
|
|
37
|
+
*.cover
|
|
38
|
+
|
|
39
|
+
# Type checking
|
|
40
|
+
.mypy_cache/
|
|
41
|
+
.ruff_cache/
|
|
42
|
+
|
|
43
|
+
# IDEs
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/
|
|
46
|
+
*.swp
|
|
47
|
+
*.swo
|
|
48
|
+
.DS_Store
|
|
49
|
+
|
|
50
|
+
# CDK
|
|
51
|
+
cdk.out/
|
|
52
|
+
cdk.context.json
|
|
53
|
+
|
|
54
|
+
# Lambda
|
|
55
|
+
*.zip
|
|
56
|
+
|
|
57
|
+
# Environment
|
|
58
|
+
.env
|
|
59
|
+
.env.local
|
|
60
|
+
.env.*.local
|
|
61
|
+
|
|
62
|
+
# Secrets
|
|
63
|
+
*.pem
|
|
64
|
+
*.key
|
|
65
|
+
secrets.yml
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
FROM public.ecr.aws/lambda/python:3.12-arm64
|
|
2
|
+
|
|
3
|
+
# Install dependencies first for better layer caching
|
|
4
|
+
COPY requirements.txt ${LAMBDA_TASK_ROOT}/
|
|
5
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
6
|
+
|
|
7
|
+
# Copy function code
|
|
8
|
+
COPY src/ ${LAMBDA_TASK_ROOT}/
|
|
9
|
+
|
|
10
|
+
# Set the handler
|
|
11
|
+
CMD [ "handler.handler" ]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const cdk = require('aws-cdk-lib');
|
|
2
|
+
const { {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack } = require('./lib/stack');
|
|
3
|
+
|
|
4
|
+
const app = new cdk.App();
|
|
5
|
+
|
|
6
|
+
// Environment from CDK context (default: dev)
|
|
7
|
+
const envName = app.node.tryGetContext('env') || 'dev';
|
|
8
|
+
|
|
9
|
+
new {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack(app, `{{ cookiecutter.project_name }}-${envName}`, {
|
|
10
|
+
stackName: `{{ cookiecutter.project_name }}-${envName}`,
|
|
11
|
+
env: {
|
|
12
|
+
account: process.env.CDK_DEFAULT_ACCOUNT,
|
|
13
|
+
region: process.env.CDK_DEFAULT_REGION || '{{ cookiecutter.aws_region }}',
|
|
14
|
+
},
|
|
15
|
+
description: `{{ cookiecutter.description }} (${envName})`,
|
|
16
|
+
tags: {
|
|
17
|
+
Project: '{{ cookiecutter.project_name }}',
|
|
18
|
+
Environment: envName,
|
|
19
|
+
ManagedBy: 'LambdaForge',
|
|
20
|
+
CostCenter: '{{ cookiecutter.project_name }}',
|
|
21
|
+
},
|
|
22
|
+
envName: envName,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
app.synth();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const cdk = require('aws-cdk-lib');
|
|
2
|
+
const lambda = require('aws-cdk-lib/aws-lambda');
|
|
3
|
+
const kinesis = require('aws-cdk-lib/aws-kinesis');
|
|
4
|
+
const dynamodb = require('aws-cdk-lib/aws-dynamodb');
|
|
5
|
+
const sqs = require('aws-cdk-lib/aws-sqs');
|
|
6
|
+
const logs = require('aws-cdk-lib/aws-logs');
|
|
7
|
+
const { KinesisEventSource } = require('aws-cdk-lib/aws-lambda-event-sources');
|
|
8
|
+
|
|
9
|
+
class {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack extends cdk.Stack {
|
|
10
|
+
constructor(scope, id, props) {
|
|
11
|
+
super(scope, id, props);
|
|
12
|
+
const envName = props.envName || 'dev';
|
|
13
|
+
|
|
14
|
+
// Kinesis Data Stream
|
|
15
|
+
const stream = new kinesis.Stream(this, 'DataStream', {
|
|
16
|
+
streamName: `{{ cookiecutter.project_name }}-stream-${envName}`,
|
|
17
|
+
shardCount: 1,
|
|
18
|
+
retentionPeriod: cdk.Duration.hours(24),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Dead Letter Queue for failed records
|
|
22
|
+
const dlq = new sqs.Queue(this, 'DeadLetterQueue', {
|
|
23
|
+
queueName: `{{ cookiecutter.project_name }}-dlq-${envName}`,
|
|
24
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// DynamoDB Table for processed results
|
|
28
|
+
const table = new dynamodb.Table(this, 'ProcessingTable', {
|
|
29
|
+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
|
|
30
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
31
|
+
pointInTimeRecovery: true,
|
|
32
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Lambda Function
|
|
36
|
+
const fn = new lambda.Function(this, 'StreamProcessorFunction', {
|
|
37
|
+
runtime: lambda.Runtime.PYTHON_3_12,
|
|
38
|
+
architecture: lambda.Architecture.ARM_64,
|
|
39
|
+
handler: 'handler.handler',
|
|
40
|
+
code: lambda.Code.fromAsset('../src'),
|
|
41
|
+
memorySize: {{ cookiecutter.memory_mb }},
|
|
42
|
+
timeout: cdk.Duration.seconds({{ cookiecutter.timeout_seconds }}),
|
|
43
|
+
tracing: lambda.Tracing.ACTIVE,
|
|
44
|
+
logRetention: logs.RetentionDays.ONE_MONTH,
|
|
45
|
+
environment: {
|
|
46
|
+
TABLE_NAME: table.tableName,
|
|
47
|
+
STAGE: envName,
|
|
48
|
+
POWERTOOLS_SERVICE_NAME: '{{ cookiecutter.project_slug }}',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Grant permissions
|
|
53
|
+
table.grantReadWriteData(fn);
|
|
54
|
+
stream.grantRead(fn);
|
|
55
|
+
dlq.grantSendMessages(fn);
|
|
56
|
+
|
|
57
|
+
// Kinesis Event Source with batch processing
|
|
58
|
+
fn.addEventSource(new KinesisEventSource(stream, {
|
|
59
|
+
batchSize: 100,
|
|
60
|
+
maxBatchingWindow: cdk.Duration.seconds(5),
|
|
61
|
+
startingPosition: lambda.StartingPosition.TRIM_HORIZON,
|
|
62
|
+
bisectBatchOnError: true,
|
|
63
|
+
retryAttempts: 3,
|
|
64
|
+
reportBatchItemFailures: true,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Tags
|
|
68
|
+
cdk.Tags.of(this).add('Project', '{{ cookiecutter.project_name }}');
|
|
69
|
+
cdk.Tags.of(this).add('ManagedBy', 'LambdaForge');
|
|
70
|
+
cdk.Tags.of(this).add('Environment', envName);
|
|
71
|
+
|
|
72
|
+
// Outputs
|
|
73
|
+
new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName });
|
|
74
|
+
new cdk.CfnOutput(this, 'StreamName', { value: stream.streamName });
|
|
75
|
+
new cdk.CfnOutput(this, 'StreamArn', { value: stream.streamArn });
|
|
76
|
+
new cdk.CfnOutput(this, 'TableName', { value: table.tableName });
|
|
77
|
+
new cdk.CfnOutput(this, 'DlqUrl', { value: dlq.queueUrl });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ cookiecutter.project_slug }}-infrastructure",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AWS CDK infrastructure for {{ cookiecutter.project_name }}",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"cdk": "cdk",
|
|
8
|
+
"synth": "cdk synth",
|
|
9
|
+
"deploy": "cdk deploy --require-approval never",
|
|
10
|
+
"destroy": "cdk destroy --force",
|
|
11
|
+
"diff": "cdk diff"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"aws-cdk-lib": "^2.170.0",
|
|
15
|
+
"constructs": "^10.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{ cookiecutter.project_slug }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "{{ cookiecutter.description }}"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["aws", "lambda", "serverless", "kinesis", "stream"]
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aws-lambda-powertools[tracer,logger]>=3.0.0",
|
|
15
|
+
"boto3>=1.34.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-cov>=4.1.0",
|
|
22
|
+
"moto[dynamodb,kinesis]>=5.0.0",
|
|
23
|
+
"ruff>=0.1.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
python_files = ["test_*.py"]
|
|
36
|
+
python_functions = ["test_*"]
|
|
37
|
+
addopts = "-v --strict-markers --cov=src --cov-report=term-missing"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "B", "UP", "N", "S", "RUF"]
|
|
45
|
+
ignore = ["S101"]
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source = ["src"]
|
|
49
|
+
omit = ["tests/*"]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.report]
|
|
52
|
+
exclude_lines = [
|
|
53
|
+
"pragma: no cover",
|
|
54
|
+
"if __name__ == .__main__.:",
|
|
55
|
+
"raise NotImplementedError",
|
|
56
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""{{ cookiecutter.project_name }} — Kinesis/DynamoDB Streams processor handler.
|
|
2
|
+
|
|
3
|
+
Processes records from a Kinesis data stream with batch checkpointing.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
from aws_lambda_powertools import Logger, Tracer
|
|
13
|
+
from aws_lambda_powertools.utilities.batch import (
|
|
14
|
+
BatchProcessor,
|
|
15
|
+
EventType,
|
|
16
|
+
process_partial_response,
|
|
17
|
+
)
|
|
18
|
+
from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import (
|
|
19
|
+
KinesisStreamRecord,
|
|
20
|
+
)
|
|
21
|
+
from aws_lambda_powertools.utilities.typing import LambdaContext
|
|
22
|
+
|
|
23
|
+
logger = Logger(service="{{ cookiecutter.project_slug }}")
|
|
24
|
+
tracer = Tracer(service="{{ cookiecutter.project_slug }}")
|
|
25
|
+
|
|
26
|
+
TABLE_NAME = os.environ.get("TABLE_NAME", "")
|
|
27
|
+
STAGE = os.environ.get("STAGE", "dev")
|
|
28
|
+
|
|
29
|
+
processor = BatchProcessor(event_type=EventType.KinesisDataStreams)
|
|
30
|
+
dynamodb = boto3.resource("dynamodb")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@tracer.capture_method
|
|
34
|
+
def process_record(record: KinesisStreamRecord) -> dict[str, Any]:
|
|
35
|
+
"""Process a single stream record.
|
|
36
|
+
|
|
37
|
+
Replace this with your business logic.
|
|
38
|
+
"""
|
|
39
|
+
payload = json.loads(record.kinesis.data_as_text())
|
|
40
|
+
sequence_number = record.kinesis.sequence_number
|
|
41
|
+
|
|
42
|
+
logger.info(
|
|
43
|
+
"processing_record",
|
|
44
|
+
extra={"sequence_number": sequence_number, "payload": payload},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
result = {
|
|
48
|
+
"sequence_number": sequence_number,
|
|
49
|
+
"status": "processed",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if TABLE_NAME:
|
|
53
|
+
table = dynamodb.Table(TABLE_NAME)
|
|
54
|
+
table.put_item(Item={"id": sequence_number, **result})
|
|
55
|
+
|
|
56
|
+
logger.info("record_processed", extra=result)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@logger.inject_lambda_context
|
|
61
|
+
@tracer.capture_lambda_handler
|
|
62
|
+
def handler(event: dict, context: LambdaContext) -> dict[str, Any]:
|
|
63
|
+
"""Lambda handler — processes Kinesis stream batch with checkpointing."""
|
|
64
|
+
return process_partial_response(
|
|
65
|
+
event=event,
|
|
66
|
+
record_handler=process_record,
|
|
67
|
+
processor=processor,
|
|
68
|
+
context=context,
|
|
69
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Test configuration and fixtures."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def aws_credentials(monkeypatch):
|
|
11
|
+
"""Mock AWS credentials for moto."""
|
|
12
|
+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
|
13
|
+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
|
14
|
+
monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing")
|
|
15
|
+
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
|
|
16
|
+
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def table_name():
|
|
21
|
+
"""Provide a test table name."""
|
|
22
|
+
return os.environ.get("TABLE_NAME", "test-table")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tests for {{ cookiecutter.project_name }} Kinesis stream processor handler."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from src.handler import handler
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def kinesis_event():
|
|
15
|
+
"""Sample Kinesis stream event."""
|
|
16
|
+
data = json.dumps({"action": "update", "entity_id": "test-123"})
|
|
17
|
+
encoded_data = base64.b64encode(data.encode()).decode()
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
"Records": [
|
|
21
|
+
{
|
|
22
|
+
"kinesis": {
|
|
23
|
+
"kinesisSchemaVersion": "1.0",
|
|
24
|
+
"partitionKey": "partition-1",
|
|
25
|
+
"sequenceNumber": "49590338271490256608559692538361571095921575989136588898",
|
|
26
|
+
"data": encoded_data,
|
|
27
|
+
"approximateArrivalTimestamp": 1704067200.0,
|
|
28
|
+
},
|
|
29
|
+
"eventSource": "aws:kinesis",
|
|
30
|
+
"eventVersion": "1.0",
|
|
31
|
+
"eventID": "shardId-000000000000:49590338271490256608559692538361571095921575989136588898",
|
|
32
|
+
"eventName": "aws:kinesis:record",
|
|
33
|
+
"invokeIdentityArn": "arn:aws:iam::123456789012:role/test-role",
|
|
34
|
+
"awsRegion": "us-east-1",
|
|
35
|
+
"eventSourceARN": "arn:aws:kinesis:us-east-1:123456789012:stream/test-stream",
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_handler_processes_stream_batch(kinesis_event):
|
|
42
|
+
"""Test handler processes Kinesis batch without error."""
|
|
43
|
+
context = MagicMock()
|
|
44
|
+
context.function_name = "test-function"
|
|
45
|
+
context.aws_request_id = "test-request-id"
|
|
46
|
+
|
|
47
|
+
with patch("src.handler.dynamodb"):
|
|
48
|
+
response = handler(kinesis_event, context)
|
|
49
|
+
|
|
50
|
+
assert "batchItemFailures" in response
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_handler_handles_empty_batch():
|
|
54
|
+
"""Test handler handles empty records list."""
|
|
55
|
+
event = {"Records": []}
|
|
56
|
+
context = MagicMock()
|
|
57
|
+
context.function_name = "test-function"
|
|
58
|
+
context.aws_request_id = "test-request-id"
|
|
59
|
+
|
|
60
|
+
with patch("src.handler.dynamodb"):
|
|
61
|
+
response = handler(event, context)
|
|
62
|
+
|
|
63
|
+
assert "batchItemFailures" in response
|
|
64
|
+
assert response["batchItemFailures"] == []
|