lambdaforge-local 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
lambdaforge/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import click
5
+
6
+ from lambdaforge.events import TRIGGERS
7
+ from lambdaforge.invoker import load_handler, invoke_handler
8
+ from lambdaforge.mocking import SUPPORTED_SERVICES, activated_mocks
9
+ from lambdaforge import output
10
+
11
+
12
+ @click.group()
13
+ def cli():
14
+ """lambdaforge — local AWS Lambda testing made simple."""
15
+
16
+
17
+ @cli.command()
18
+ @click.option(
19
+ "--trigger",
20
+ required=True,
21
+ type=click.Choice(list(TRIGGERS.keys())),
22
+ help="Lambda trigger type",
23
+ )
24
+ @click.option(
25
+ "--output",
26
+ "output_path",
27
+ default="event.json",
28
+ show_default=True,
29
+ help="Output file path",
30
+ )
31
+ def generate(trigger: str, output_path: str) -> None:
32
+ """Generate a base event JSON file for the given trigger type."""
33
+ event = TRIGGERS[trigger]()
34
+ path = Path(output_path)
35
+ path.write_text(json.dumps(event, indent=2))
36
+ click.echo(f"Event written to {output_path}")
37
+
38
+
39
+ @cli.command()
40
+ @click.option(
41
+ "--handler",
42
+ "handler_spec",
43
+ required=True,
44
+ help="Handler: path/to/file.py::function_name",
45
+ )
46
+ @click.option(
47
+ "--event",
48
+ "event_path",
49
+ default="event.json",
50
+ show_default=True,
51
+ help="Event JSON file path",
52
+ )
53
+ @click.option(
54
+ "--mock",
55
+ "mock_str",
56
+ default="",
57
+ help="Comma-separated AWS services to mock, e.g. dynamodb,s3",
58
+ )
59
+ def invoke(handler_spec: str, event_path: str, mock_str: str) -> None:
60
+ """Invoke a Lambda handler locally."""
61
+ # Validate and parse mock services
62
+ services = [s.strip() for s in mock_str.split(",") if s.strip()]
63
+ for svc in services:
64
+ if svc not in SUPPORTED_SERVICES:
65
+ valid = ", ".join(sorted(SUPPORTED_SERVICES))
66
+ click.echo(
67
+ f'Error: unknown service "{svc}". Valid options: {valid}', err=True
68
+ )
69
+ raise SystemExit(1)
70
+
71
+ # Validate handler path
72
+ if "::" not in handler_spec:
73
+ click.echo(
74
+ "Error: invalid handler format. Use path/to/file.py::function_name",
75
+ err=True,
76
+ )
77
+ raise SystemExit(1)
78
+ handler_file = handler_spec.split("::")[0]
79
+ if not Path(handler_file).exists():
80
+ click.echo(f"Error: handler file not found: {handler_file}", err=True)
81
+ raise SystemExit(1)
82
+
83
+ # Validate event file
84
+ event_file = Path(event_path)
85
+ if not event_file.exists():
86
+ click.echo(f"Error: event file not found: {event_path}", err=True)
87
+ raise SystemExit(1)
88
+ try:
89
+ event = json.loads(event_file.read_text())
90
+ except json.JSONDecodeError:
91
+ click.echo(f"Error: event file is not valid JSON: {event_path}", err=True)
92
+ raise SystemExit(1)
93
+
94
+ # Load handler
95
+ try:
96
+ handler = load_handler(handler_spec)
97
+ except Exception as exc:
98
+ click.echo(f"Error loading handler: {exc}", err=True)
99
+ raise SystemExit(1)
100
+
101
+ output.print_header(handler_spec, event_path, services)
102
+
103
+ # Invoke with or without mocks
104
+ try:
105
+ if services:
106
+ with activated_mocks(services):
107
+ result, logs, duration_ms = invoke_handler(handler, event)
108
+ else:
109
+ result, logs, duration_ms = invoke_handler(handler, event)
110
+ except Exception:
111
+ output.print_error()
112
+ raise SystemExit(1)
113
+
114
+ output.print_logs(logs)
115
+ output.print_result(result)
116
+ output.print_duration(duration_ms)
@@ -0,0 +1,9 @@
1
+ from lambdaforge.events import apigateway, sqs, s3, sns, eventbridge
2
+
3
+ TRIGGERS: dict = {
4
+ "apigateway": apigateway.generate,
5
+ "sqs": sqs.generate,
6
+ "s3": s3.generate,
7
+ "sns": sns.generate,
8
+ "eventbridge": eventbridge.generate,
9
+ }
@@ -0,0 +1,16 @@
1
+ def generate() -> dict:
2
+ return {
3
+ "httpMethod": "GET",
4
+ "path": "<your-path>",
5
+ "pathParameters": None,
6
+ "queryStringParameters": None,
7
+ "headers": {
8
+ "Content-Type": "application/json",
9
+ },
10
+ "body": "<your-json-body-or-null>",
11
+ "isBase64Encoded": False,
12
+ "requestContext": {
13
+ "stage": "local",
14
+ "requestId": "local-request-id",
15
+ },
16
+ }
@@ -0,0 +1,12 @@
1
+ def generate() -> dict:
2
+ return {
3
+ "version": "0",
4
+ "id": "local-event-id",
5
+ "source": "<your-event-source>",
6
+ "account": "000000000000",
7
+ "time": "2024-01-01T00:00:00Z",
8
+ "region": "us-east-1",
9
+ "resources": [],
10
+ "detail-type": "<your-detail-type>",
11
+ "detail": {},
12
+ }
@@ -0,0 +1,25 @@
1
+ def generate() -> dict:
2
+ return {
3
+ "Records": [
4
+ {
5
+ "eventVersion": "2.1",
6
+ "eventSource": "aws:s3",
7
+ "awsRegion": "us-east-1",
8
+ "eventTime": "2024-01-01T00:00:00.000Z",
9
+ "eventName": "ObjectCreated:Put",
10
+ "s3": {
11
+ "s3SchemaVersion": "1.0",
12
+ "configurationId": "local",
13
+ "bucket": {
14
+ "name": "<your-bucket-name>",
15
+ "arn": "arn:aws:s3:::<your-bucket-name>",
16
+ },
17
+ "object": {
18
+ "key": "<your-object-key>",
19
+ "size": 1024,
20
+ "eTag": "local-etag",
21
+ },
22
+ },
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,23 @@
1
+ def generate() -> dict:
2
+ return {
3
+ "Records": [
4
+ {
5
+ "EventVersion": "1.0",
6
+ "EventSubscriptionArn": "arn:aws:sns:us-east-1:000000000000:<your-topic-name>:local",
7
+ "EventSource": "aws:sns",
8
+ "Sns": {
9
+ "SignatureVersion": "1",
10
+ "Timestamp": "2024-01-01T00:00:00.000Z",
11
+ "Signature": "local-signature",
12
+ "SigningCertUrl": "local",
13
+ "MessageId": "<your-message-id>",
14
+ "Message": "<your-message-body>",
15
+ "MessageAttributes": {},
16
+ "Type": "Notification",
17
+ "UnsubscribeUrl": "local",
18
+ "TopicArn": "arn:aws:sns:us-east-1:000000000000:<your-topic-name>",
19
+ "Subject": "<your-subject-or-null>",
20
+ },
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ def generate() -> dict:
2
+ return {
3
+ "Records": [
4
+ {
5
+ "messageId": "<your-message-id>",
6
+ "receiptHandle": "local-receipt-handle",
7
+ "body": "<your-message-body>",
8
+ "attributes": {
9
+ "ApproximateReceiveCount": "1",
10
+ "SentTimestamp": "1640000000000",
11
+ "SenderId": "000000000000",
12
+ "ApproximateFirstReceiveTimestamp": "1640000000001",
13
+ },
14
+ "messageAttributes": {},
15
+ "md5OfBody": "local-md5",
16
+ "eventSource": "aws:sqs",
17
+ "eventSourceARN": "arn:aws:sqs:us-east-1:000000000000:<your-queue-name>",
18
+ "awsRegion": "us-east-1",
19
+ }
20
+ ]
21
+ }
lambdaforge/invoker.py ADDED
@@ -0,0 +1,80 @@
1
+ import importlib.util
2
+ import io
3
+ import logging
4
+ import time
5
+ import uuid
6
+ from contextlib import redirect_stdout
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+
12
+ @dataclass
13
+ class LambdaContext:
14
+ function_name: str = "lambdaforge-local"
15
+ function_version: str = "$LATEST"
16
+ invoked_function_arn: str = (
17
+ "arn:aws:lambda:us-east-1:000000000000:function:lambdaforge-local"
18
+ )
19
+ memory_limit_in_mb: int = 128
20
+ aws_request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
21
+ log_group_name: str = "/aws/lambda/lambdaforge-local"
22
+ log_stream_name: str = "local"
23
+
24
+ def get_remaining_time_in_millis(self) -> int:
25
+ return 30000
26
+
27
+
28
+ def load_handler(handler_spec: str) -> Callable:
29
+ """Load a handler callable from 'path/to/file.py::function_name'.
30
+
31
+ The file path is resolved relative to the current working directory.
32
+ Raises ValueError if the format is invalid, FileNotFoundError if the file
33
+ does not exist.
34
+ """
35
+ if "::" not in handler_spec:
36
+ raise ValueError(
37
+ f"Invalid handler format '{handler_spec}'. "
38
+ "Use path/to/file.py::function_name"
39
+ )
40
+
41
+ file_path, func_name = handler_spec.rsplit("::", 1)
42
+ path = Path(file_path)
43
+
44
+ if not path.exists():
45
+ raise FileNotFoundError(file_path)
46
+
47
+ spec = importlib.util.spec_from_file_location("_lambdaforge_handler", path)
48
+ module = importlib.util.module_from_spec(spec)
49
+ spec.loader.exec_module(module)
50
+
51
+ return getattr(module, func_name)
52
+
53
+
54
+ def invoke_handler(
55
+ handler: Callable, event: dict
56
+ ) -> tuple[Any, str, float]:
57
+ """Invoke handler(event, context), capturing stdout and logging output.
58
+
59
+ Returns (return_value, captured_logs, duration_ms).
60
+ Exceptions from the handler propagate to the caller unchanged.
61
+ """
62
+ context = LambdaContext()
63
+ captured = io.StringIO()
64
+
65
+ log_handler = logging.StreamHandler(captured)
66
+ log_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
67
+ root_logger = logging.getLogger()
68
+ root_logger.addHandler(log_handler)
69
+
70
+ start = time.perf_counter()
71
+ try:
72
+ with redirect_stdout(captured):
73
+ result = handler(event, context)
74
+ finally:
75
+ root_logger.removeHandler(log_handler)
76
+
77
+ duration_ms = (time.perf_counter() - start) * 1000
78
+ logs = captured.getvalue()
79
+
80
+ return result, logs, duration_ms
lambdaforge/mocking.py ADDED
@@ -0,0 +1,16 @@
1
+ from contextlib import contextmanager
2
+ from moto import mock_aws
3
+
4
+ SUPPORTED_SERVICES: set[str] = {"dynamodb", "s3", "ssm", "sqs", "sns"}
5
+
6
+
7
+ @contextmanager
8
+ def activated_mocks(services: list[str]):
9
+ """Activate moto mock_aws() context manager.
10
+
11
+ All boto3 calls are intercepted regardless of which services are listed.
12
+ The services list is used for display/validation only — mock_aws() intercepts
13
+ everything uniformly. Mocked state is empty at start; see v1 limitation docs.
14
+ """
15
+ with mock_aws():
16
+ yield
lambdaforge/output.py ADDED
@@ -0,0 +1,42 @@
1
+ import json
2
+
3
+ from rich.console import Console
4
+ from rich.syntax import Syntax
5
+
6
+ console = Console()
7
+
8
+
9
+ def print_header(handler_spec: str, event_path: str, services: list[str]) -> None:
10
+ console.print(f"[green]✓[/green] Handler: {handler_spec}")
11
+ console.print(f"[green]✓[/green] Event: {event_path}")
12
+ if services:
13
+ console.print(f"[green]✓[/green] Mocks: {', '.join(services)}")
14
+ console.print()
15
+
16
+
17
+ def print_logs(logs: str) -> None:
18
+ if not logs.strip():
19
+ return
20
+ console.rule("Logs")
21
+ console.print(logs.rstrip())
22
+ console.print()
23
+
24
+
25
+ def print_result(result: object) -> None:
26
+ console.rule("Return value")
27
+ try:
28
+ formatted = json.dumps(result, indent=2)
29
+ console.print(Syntax(formatted, "json", theme="monokai"))
30
+ except (TypeError, ValueError):
31
+ console.print(str(result))
32
+ console.print()
33
+
34
+
35
+ def print_duration(duration_ms: float) -> None:
36
+ console.rule()
37
+ console.print(f"Duration: {duration_ms:.0f}ms")
38
+
39
+
40
+ def print_error() -> None:
41
+ console.rule("[red]Error[/red]")
42
+ console.print_exception(show_locals=False)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: lambdaforge-local
3
+ Version: 0.1.0
4
+ Summary: Local AWS Lambda testing: generate realistic trigger events and invoke handlers with mocked AWS services
5
+ Project-URL: Homepage, https://github.com/TretiqHiks/lambdaforge
6
+ Project-URL: Source, https://github.com/TretiqHiks/lambdaforge
7
+ Author: TretiqHiks
8
+ License: MIT
9
+ Keywords: aws,cli,lambda,local,moto,testing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: boto3>=1.26
21
+ Requires-Dist: click>=8.0
22
+ Requires-Dist: moto[all]>=4.0
23
+ Requires-Dist: rich>=13.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # lambdaforge
29
+
30
+ > Local AWS Lambda testing made simple.
31
+
32
+ Generate realistic trigger event payloads and invoke your Python Lambda handler locally — with optional in-memory AWS service mocking via [moto](https://docs.getmoto.org/). No Docker. No SAM. No LocalStack.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install lambdaforge
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # 1. Generate a realistic event file for your trigger type
44
+ lambdaforge generate --trigger apigateway
45
+
46
+ # 2. Edit event.json with your actual path, body, etc.
47
+
48
+ # 3. Invoke your handler
49
+ lambdaforge invoke --handler src/handler.py::lambda_handler
50
+
51
+ # With mocked DynamoDB and S3 (boto3 calls intercepted, no real AWS)
52
+ lambdaforge invoke --handler src/handler.py::lambda_handler --mock dynamodb,s3
53
+ ```
54
+
55
+ ## Supported Triggers
56
+
57
+ | Trigger | CLI value |
58
+ |---|---|
59
+ | API Gateway REST proxy | `apigateway` |
60
+ | SQS | `sqs` |
61
+ | S3 object created | `s3` |
62
+ | SNS | `sns` |
63
+ | EventBridge / scheduled | `eventbridge` |
64
+
65
+ ## Supported Mock Services
66
+
67
+ Pass any combination via `--mock`: `dynamodb`, `s3`, `ssm`, `sqs`, `sns`
68
+
69
+ > **Note:** Mocked services start empty. Pre-populate state in your handler behind an env var guard if needed. Seed file support is planned for v2.
70
+
71
+ ## How It Works
72
+
73
+ **`generate`** writes a complete AWS event JSON to a file (default: `event.json`). User-supplied fields are marked with `"<placeholder>"` strings.
74
+
75
+ **`invoke`** loads your handler via Python's import system, builds a mock `LambdaContext`, optionally activates moto's `mock_aws()` context, and calls `handler(event, context)`. Output is formatted with [rich](https://github.com/Textualize/rich).
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,15 @@
1
+ lambdaforge/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ lambdaforge/cli.py,sha256=9KDt9eBl8YyAtq8VySx9VYfAKvknAePTNbzHcY4E93Y,3331
3
+ lambdaforge/invoker.py,sha256=SATvVl1V6WuuYWSjPg3D4-7F4SWc8NHvic1jV_xeJcc,2426
4
+ lambdaforge/mocking.py,sha256=QsTGWiAsOC-XW_0_fZsNat_jOmR8v6e89Jc3v-PQrOw,532
5
+ lambdaforge/output.py,sha256=9yplJWl0nuwH2Ng5DGK5TvWm_-rJGc_73oKHskU9MRc,1105
6
+ lambdaforge/events/__init__.py,sha256=JyAW7pYsZGVcieYsHXszWJlVY7HsbM1vw1yJAXZXzck,244
7
+ lambdaforge/events/apigateway.py,sha256=Mu9lDp5j0BUlot0zlmYsBBPZGjTdWxVX1gt_NkKU7eI,446
8
+ lambdaforge/events/eventbridge.py,sha256=w-HbyjlHcMIuB1OUhY1jvmsvOZvYR9K4YSfxxWr69Bk,338
9
+ lambdaforge/events/s3.py,sha256=npygoO24yjtS_W1Ae2ooXOxvlV3IhWC42a3vOy2IbFk,834
10
+ lambdaforge/events/sns.py,sha256=KnBy1KVP14piK2JF74t7G7yAd_z8398m8P7EgXu_kOE,923
11
+ lambdaforge/events/sqs.py,sha256=j4ZH2jv-6PKduEzo5yKSn1bc0vtAMBeDASYuScOEN3k,791
12
+ lambdaforge_local-0.1.0.dist-info/METADATA,sha256=GbOACFeXaoneABIcfGB-5SxKjEnp2nSTjfsdSS3V5qs,2679
13
+ lambdaforge_local-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ lambdaforge_local-0.1.0.dist-info/entry_points.txt,sha256=QJz2Gkh7GMnqAxgchX9iz2e5CzazNzRsTd88mtUjHhc,52
15
+ lambdaforge_local-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lambdaforge = lambdaforge.cli:cli