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
lambdaforge/deploy.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Deploy command — deploys the current Lambda to AWS using CDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def deploy_lambda(
|
|
16
|
+
project_path: Path,
|
|
17
|
+
stack_name: str | None = None,
|
|
18
|
+
env: str = "dev",
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Deploy a Lambda project to AWS.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
project_path: Path to the Lambda project
|
|
24
|
+
stack_name: Optional CDK stack name to deploy (deploys all if not specified)
|
|
25
|
+
env: Target environment (dev, staging, prod)
|
|
26
|
+
"""
|
|
27
|
+
infra_dir = project_path / "infrastructure"
|
|
28
|
+
if not infra_dir.exists():
|
|
29
|
+
console.print(f"[red]✗ No infrastructure/ directory found in {project_path}[/red]")
|
|
30
|
+
console.print("[yellow]Run `lambdaforge new` first or set up CDK manually.[/yellow]")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Pre-flight checks
|
|
34
|
+
if not _preflight_check(project_path):
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Confirm prod deploy
|
|
38
|
+
if env == "prod":
|
|
39
|
+
console.print("[yellow]⚠ Deploying to PRODUCTION environment[/yellow]")
|
|
40
|
+
import click
|
|
41
|
+
|
|
42
|
+
if not click.confirm("Are you sure?", default=False):
|
|
43
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Bundle Python dependencies
|
|
47
|
+
console.print("[dim]Bundling Python dependencies...[/dim]")
|
|
48
|
+
if not _bundle_dependencies(project_path):
|
|
49
|
+
console.print("[yellow]⚠ Dependency bundling failed. Deploy may fail at runtime.[/yellow]")
|
|
50
|
+
|
|
51
|
+
# Install CDK dependencies
|
|
52
|
+
if (infra_dir / "package.json").exists():
|
|
53
|
+
if not (infra_dir / "node_modules").exists():
|
|
54
|
+
console.print("[dim]Installing CDK dependencies...[/dim]")
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["npm", "install"],
|
|
57
|
+
cwd=infra_dir,
|
|
58
|
+
check=True,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Build cdk deploy command with environment context
|
|
63
|
+
cmd = ["npx", "cdk", "deploy", "--require-approval", "never", "-c", f"env={env}"]
|
|
64
|
+
if stack_name:
|
|
65
|
+
cmd.append(stack_name)
|
|
66
|
+
|
|
67
|
+
# Run cdk deploy with progress display
|
|
68
|
+
console.print(f"[dim]Deploying to [bold]{env}[/bold]...[/dim]")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
_run_with_progress(cmd, cwd=infra_dir)
|
|
72
|
+
console.print(f"[green]✓ Deployment successful ({env})[/green]")
|
|
73
|
+
except subprocess.CalledProcessError as e:
|
|
74
|
+
console.print(f"[red]✗ Deployment failed (exit code {e.returncode})[/red]")
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
console.print("[red]✗ npx/cdk command not found[/red]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def bootstrap_account(region: str = "us-east-1") -> None:
|
|
80
|
+
"""Bootstrap CDK in the AWS account/region.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
region: AWS region to bootstrap
|
|
84
|
+
"""
|
|
85
|
+
if not shutil.which("node"):
|
|
86
|
+
console.print("[red]✗ Node.js not found. Install Node.js 18+ first.[/red]")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
["aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text"],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
check=True,
|
|
95
|
+
)
|
|
96
|
+
account_id = result.stdout.strip()
|
|
97
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
98
|
+
console.print("[red]✗ Cannot determine AWS account. Check AWS credentials.[/red]")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
console.print(f"[dim]Bootstrapping CDK in {account_id}/{region}...[/dim]")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
subprocess.run(
|
|
105
|
+
["npx", "cdk", "bootstrap", f"aws://{account_id}/{region}"],
|
|
106
|
+
check=True,
|
|
107
|
+
)
|
|
108
|
+
console.print(f"[green]✓ CDK bootstrapped in {account_id}/{region}[/green]")
|
|
109
|
+
except subprocess.CalledProcessError as e:
|
|
110
|
+
console.print(f"[red]✗ Bootstrap failed (exit code {e.returncode})[/red]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def destroy_stack(
|
|
114
|
+
project_path: Path,
|
|
115
|
+
stack_name: str | None = None,
|
|
116
|
+
env: str = "dev",
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Destroy the CDK stack for a Lambda project.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
project_path: Path to the Lambda project
|
|
122
|
+
stack_name: Optional stack name (auto-detected if not provided)
|
|
123
|
+
env: Target environment to destroy
|
|
124
|
+
"""
|
|
125
|
+
infra_dir = project_path / "infrastructure"
|
|
126
|
+
if not infra_dir.exists():
|
|
127
|
+
console.print("[red]✗ No infrastructure/ directory found[/red]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
cmd = ["npx", "cdk", "destroy", "--force", "-c", f"env={env}"]
|
|
131
|
+
if stack_name:
|
|
132
|
+
cmd.append(stack_name)
|
|
133
|
+
|
|
134
|
+
console.print(f"[dim]Destroying stack ({env})...[/dim]")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
subprocess.run(cmd, cwd=infra_dir, check=True)
|
|
138
|
+
console.print(f"[green]✓ Stack destroyed ({env})[/green]")
|
|
139
|
+
except subprocess.CalledProcessError as e:
|
|
140
|
+
console.print(f"[red]✗ Destroy failed (exit code {e.returncode})[/red]")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _preflight_check(project_path: Path) -> bool:
|
|
144
|
+
"""Run pre-flight checks before deploying. Returns True if all pass."""
|
|
145
|
+
errors = []
|
|
146
|
+
|
|
147
|
+
if not shutil.which("node"):
|
|
148
|
+
errors.append("Node.js not found. Install Node.js 18+.")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
subprocess.run(
|
|
152
|
+
["aws", "sts", "get-caller-identity"],
|
|
153
|
+
capture_output=True,
|
|
154
|
+
check=True,
|
|
155
|
+
)
|
|
156
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
157
|
+
errors.append("AWS credentials not configured. Run `aws configure`.")
|
|
158
|
+
|
|
159
|
+
src_dir = project_path / "src"
|
|
160
|
+
if not src_dir.exists():
|
|
161
|
+
errors.append(f"Source directory not found: {src_dir}")
|
|
162
|
+
|
|
163
|
+
if errors:
|
|
164
|
+
console.print("[red]✗ Pre-flight checks failed:[/red]")
|
|
165
|
+
for err in errors:
|
|
166
|
+
console.print(f" [red]• {err}[/red]")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _bundle_dependencies(project_path: Path) -> bool:
|
|
173
|
+
"""Bundle Python dependencies into src/ for Lambda deployment.
|
|
174
|
+
|
|
175
|
+
Installs requirements.txt targeting Linux ARM64.
|
|
176
|
+
Returns True on success.
|
|
177
|
+
"""
|
|
178
|
+
src_dir = project_path / "src"
|
|
179
|
+
requirements = project_path / "requirements.txt"
|
|
180
|
+
|
|
181
|
+
if not requirements.exists():
|
|
182
|
+
console.print("[dim]No requirements.txt found, skipping bundling.[/dim]")
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
subprocess.run(
|
|
187
|
+
[
|
|
188
|
+
sys.executable,
|
|
189
|
+
"-m",
|
|
190
|
+
"pip",
|
|
191
|
+
"install",
|
|
192
|
+
"--target",
|
|
193
|
+
str(src_dir),
|
|
194
|
+
"--platform",
|
|
195
|
+
"manylinux2014_aarch64",
|
|
196
|
+
"--only-binary=:all:",
|
|
197
|
+
"--python-version",
|
|
198
|
+
"3.12",
|
|
199
|
+
"--upgrade",
|
|
200
|
+
"--quiet",
|
|
201
|
+
"-r",
|
|
202
|
+
str(requirements),
|
|
203
|
+
],
|
|
204
|
+
check=True,
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
)
|
|
208
|
+
console.print("[green]✓ Dependencies bundled[/green]")
|
|
209
|
+
return True
|
|
210
|
+
except subprocess.CalledProcessError:
|
|
211
|
+
# Fallback: install without platform constraint (pure Python packages)
|
|
212
|
+
try:
|
|
213
|
+
subprocess.run(
|
|
214
|
+
[
|
|
215
|
+
sys.executable,
|
|
216
|
+
"-m",
|
|
217
|
+
"pip",
|
|
218
|
+
"install",
|
|
219
|
+
"--target",
|
|
220
|
+
str(src_dir),
|
|
221
|
+
"--upgrade",
|
|
222
|
+
"--quiet",
|
|
223
|
+
"-r",
|
|
224
|
+
str(requirements),
|
|
225
|
+
],
|
|
226
|
+
check=True,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
text=True,
|
|
229
|
+
)
|
|
230
|
+
console.print("[green]✓ Dependencies bundled (fallback)[/green]")
|
|
231
|
+
return True
|
|
232
|
+
except subprocess.CalledProcessError:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _run_with_progress(cmd: list[str], cwd: Path) -> None:
|
|
237
|
+
"""Run a command showing Rich progress based on CDK output.
|
|
238
|
+
|
|
239
|
+
Parses CDK output lines to show a live status spinner with
|
|
240
|
+
the current resource being created/updated/deleted.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
cmd: Command as list (e.g., ["npx", "cdk", "deploy", ...])
|
|
244
|
+
cwd: Working directory
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
subprocess.CalledProcessError: If the command fails
|
|
248
|
+
"""
|
|
249
|
+
import re
|
|
250
|
+
|
|
251
|
+
from rich.live import Live
|
|
252
|
+
from rich.spinner import Spinner
|
|
253
|
+
|
|
254
|
+
resources_done = 0
|
|
255
|
+
outputs: list[str] = []
|
|
256
|
+
|
|
257
|
+
process = subprocess.Popen(
|
|
258
|
+
cmd,
|
|
259
|
+
cwd=cwd,
|
|
260
|
+
stdout=subprocess.PIPE,
|
|
261
|
+
stderr=subprocess.STDOUT,
|
|
262
|
+
text=True,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Pattern: StackName | 3/12 | timestamp | STATUS | AWS::Type | Name
|
|
266
|
+
progress_pattern = re.compile(
|
|
267
|
+
r"\|\s*(\d+)/(\d+)\s*\|.*?\|\s*([\w_]+)\s*\|\s*([\w:]+)\s*\|\s*(.*)"
|
|
268
|
+
)
|
|
269
|
+
output_pattern = re.compile(r"^(\w[\w.-]+)\s*=\s*(.+)$")
|
|
270
|
+
|
|
271
|
+
with Live(Spinner("dots", text="Starting deployment..."), console=console, transient=True):
|
|
272
|
+
assert process.stdout is not None
|
|
273
|
+
for line in iter(process.stdout.readline, ""):
|
|
274
|
+
line = line.strip()
|
|
275
|
+
if not line:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
match = progress_pattern.search(line)
|
|
279
|
+
if match:
|
|
280
|
+
done, total, status, resource_type, name = match.groups()
|
|
281
|
+
resources_done = int(done)
|
|
282
|
+
total_resources = int(total)
|
|
283
|
+
short_type = resource_type.split("::")[-1]
|
|
284
|
+
short_name = name.split("(")[-1].rstrip(")")[:30] if "(" in name else name[:30]
|
|
285
|
+
|
|
286
|
+
if "COMPLETE" in status and "ROLLBACK" not in status:
|
|
287
|
+
console.print(
|
|
288
|
+
f" [green]✓[/green] {short_type} ({short_name}) "
|
|
289
|
+
f"[dim]{resources_done}/{total_resources}[/dim]"
|
|
290
|
+
)
|
|
291
|
+
elif "FAILED" in status:
|
|
292
|
+
console.print(f" [red]✗[/red] {short_type} ({short_name}) — {status}")
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
output_match = output_pattern.match(line)
|
|
296
|
+
if output_match:
|
|
297
|
+
outputs.append(line)
|
|
298
|
+
|
|
299
|
+
process.wait()
|
|
300
|
+
|
|
301
|
+
if outputs:
|
|
302
|
+
console.print()
|
|
303
|
+
console.print("[bold]Outputs:[/bold]")
|
|
304
|
+
for out in outputs:
|
|
305
|
+
console.print(f" [cyan]{out}[/cyan]")
|
|
306
|
+
|
|
307
|
+
if process.returncode != 0:
|
|
308
|
+
raise subprocess.CalledProcessError(process.returncode, cmd)
|
lambdaforge/logs.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Logs command — tail CloudWatch logs for the deployed Lambda."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def show_logs(
|
|
16
|
+
project_path: Path,
|
|
17
|
+
env: str = "dev",
|
|
18
|
+
lines: int = 50,
|
|
19
|
+
errors_only: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Show CloudWatch logs for the deployed Lambda.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
project_path: Path to the Lambda project
|
|
25
|
+
env: Target environment
|
|
26
|
+
lines: Number of log lines to retrieve
|
|
27
|
+
errors_only: Only show ERROR level logs
|
|
28
|
+
"""
|
|
29
|
+
project_name = project_path.name.replace("_", "-")
|
|
30
|
+
log_group = f"/aws/lambda/{project_name}-{env}"
|
|
31
|
+
|
|
32
|
+
if not _log_group_exists(log_group):
|
|
33
|
+
slug = project_path.name.replace("-", "_")
|
|
34
|
+
log_group = f"/aws/lambda/{slug}-{env}"
|
|
35
|
+
if not _log_group_exists(log_group):
|
|
36
|
+
console.print(f"[yellow]No logs found for {project_name} ({env})[/yellow]")
|
|
37
|
+
console.print("[dim]Has the Lambda been invoked at least once?[/dim]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
console.print(f"[dim]Log group: {log_group}[/dim]")
|
|
41
|
+
console.print(f"[dim]Last {lines} events{' (errors only)' if errors_only else ''}[/dim]")
|
|
42
|
+
console.print()
|
|
43
|
+
|
|
44
|
+
events = _get_log_events(log_group, limit=lines)
|
|
45
|
+
|
|
46
|
+
if not events:
|
|
47
|
+
console.print("[yellow]No log events found.[/yellow]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if errors_only:
|
|
51
|
+
events = [e for e in events if _is_error_event(e)]
|
|
52
|
+
if not events:
|
|
53
|
+
console.print("[green]No errors found.[/green]")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
for event in events:
|
|
57
|
+
_print_log_event(event)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _log_group_exists(log_group: str) -> bool:
|
|
61
|
+
"""Check if a CloudWatch log group exists."""
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(
|
|
64
|
+
[
|
|
65
|
+
"aws",
|
|
66
|
+
"logs",
|
|
67
|
+
"describe-log-groups",
|
|
68
|
+
"--log-group-name-prefix",
|
|
69
|
+
log_group,
|
|
70
|
+
"--limit",
|
|
71
|
+
"1",
|
|
72
|
+
"--output",
|
|
73
|
+
"json",
|
|
74
|
+
],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=True,
|
|
78
|
+
)
|
|
79
|
+
return True
|
|
80
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_log_events(log_group: str, limit: int = 50) -> list[dict[str, Any]]:
|
|
85
|
+
"""Fetch recent log events from CloudWatch."""
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
[
|
|
89
|
+
"aws",
|
|
90
|
+
"logs",
|
|
91
|
+
"describe-log-streams",
|
|
92
|
+
"--log-group-name",
|
|
93
|
+
log_group,
|
|
94
|
+
"--order-by",
|
|
95
|
+
"LastEventTime",
|
|
96
|
+
"--descending",
|
|
97
|
+
"--limit",
|
|
98
|
+
"3",
|
|
99
|
+
"--output",
|
|
100
|
+
"json",
|
|
101
|
+
],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
check=True,
|
|
105
|
+
)
|
|
106
|
+
streams_data = json.loads(result.stdout)
|
|
107
|
+
streams = streams_data.get("logStreams", [])
|
|
108
|
+
|
|
109
|
+
if not streams:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
all_events = []
|
|
113
|
+
for stream in streams[:3]:
|
|
114
|
+
stream_name = stream["logStreamName"]
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
[
|
|
117
|
+
"aws",
|
|
118
|
+
"logs",
|
|
119
|
+
"get-log-events",
|
|
120
|
+
"--log-group-name",
|
|
121
|
+
log_group,
|
|
122
|
+
"--log-stream-name",
|
|
123
|
+
stream_name,
|
|
124
|
+
"--limit",
|
|
125
|
+
str(min(limit, 50)),
|
|
126
|
+
"--output",
|
|
127
|
+
"json",
|
|
128
|
+
],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
check=True,
|
|
132
|
+
)
|
|
133
|
+
events_data = json.loads(result.stdout)
|
|
134
|
+
events = events_data.get("events", [])
|
|
135
|
+
all_events.extend(events)
|
|
136
|
+
|
|
137
|
+
all_events.sort(key=lambda e: e.get("timestamp", 0))
|
|
138
|
+
return all_events[-limit:]
|
|
139
|
+
|
|
140
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_error_event(event: dict[str, Any]) -> bool:
|
|
145
|
+
"""Check if a log event is an error."""
|
|
146
|
+
message = event.get("message", "")
|
|
147
|
+
error_indicators = ["[ERROR]", "ERROR", "Traceback", "Exception", "FAILED"]
|
|
148
|
+
return any(indicator in message for indicator in error_indicators)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _print_log_event(event: dict[str, Any]) -> None:
|
|
152
|
+
"""Print a single log event with formatting."""
|
|
153
|
+
message = event.get("message", "").strip()
|
|
154
|
+
|
|
155
|
+
if not message:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Dim metadata lines
|
|
159
|
+
if message.startswith(("REPORT ", "START ", "END ", "XRAY ")):
|
|
160
|
+
console.print(f"[dim]{message}[/dim]")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Try structured JSON (aws-lambda-powertools format)
|
|
164
|
+
try:
|
|
165
|
+
parsed = json.loads(message)
|
|
166
|
+
level = parsed.get("level", "INFO")
|
|
167
|
+
color = "red" if level == "ERROR" else "yellow" if level == "WARNING" else "cyan"
|
|
168
|
+
service = parsed.get("service", "")
|
|
169
|
+
msg = parsed.get("message", message)
|
|
170
|
+
timestamp = parsed.get("timestamp", "")
|
|
171
|
+
|
|
172
|
+
console.print(
|
|
173
|
+
f"[dim]{timestamp}[/dim] [{color}]{level}[/{color}] [bold]{service}[/bold] {msg}"
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
except (json.JSONDecodeError, TypeError):
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
# Plain text with color hints
|
|
180
|
+
if "[ERROR]" in message or "Exception" in message:
|
|
181
|
+
console.print(f"[red]{message}[/red]")
|
|
182
|
+
elif "[WARNING]" in message:
|
|
183
|
+
console.print(f"[yellow]{message}[/yellow]")
|
|
184
|
+
else:
|
|
185
|
+
console.print(message)
|
lambdaforge/renderer.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Template renderer — wraps Cookiecutter for project generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cookiecutter.main import cookiecutter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TemplateRenderer:
|
|
11
|
+
"""Renders Cookiecutter templates into project directories."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, templates_dir: Path | None = None) -> None:
|
|
14
|
+
if templates_dir is None:
|
|
15
|
+
# Try multiple locations for bundled templates
|
|
16
|
+
candidates = [
|
|
17
|
+
# Installed package: templates/ at same level as lambdaforge/
|
|
18
|
+
Path(__file__).parent.parent / "templates",
|
|
19
|
+
# Development mode (pip install -e .): repo_root/templates/
|
|
20
|
+
Path(__file__).parent.parent.parent / "templates",
|
|
21
|
+
]
|
|
22
|
+
for candidate in candidates:
|
|
23
|
+
if candidate.exists() and any(candidate.iterdir()):
|
|
24
|
+
self.templates_dir = candidate
|
|
25
|
+
break
|
|
26
|
+
else:
|
|
27
|
+
self.templates_dir = candidates[0]
|
|
28
|
+
else:
|
|
29
|
+
self.templates_dir = templates_dir
|
|
30
|
+
|
|
31
|
+
def render(
|
|
32
|
+
self,
|
|
33
|
+
template_name: str,
|
|
34
|
+
project_name: str,
|
|
35
|
+
context: dict,
|
|
36
|
+
output_dir: str = ".",
|
|
37
|
+
) -> Path:
|
|
38
|
+
"""Render a template into a project directory.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
template_name: Name of the template (e.g., "python-api")
|
|
42
|
+
project_name: Name of the new project
|
|
43
|
+
context: Variables to pass to the template
|
|
44
|
+
output_dir: Where to create the project
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Path to the created project
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
FileNotFoundError: If template doesn't exist
|
|
51
|
+
"""
|
|
52
|
+
template_path = self.templates_dir / template_name
|
|
53
|
+
if not template_path.exists():
|
|
54
|
+
raise FileNotFoundError(f"Template not found: {template_path}")
|
|
55
|
+
|
|
56
|
+
# Cookiecutter requires the template to be a git repo or have a specific structure
|
|
57
|
+
# For simplicity, we use the project_name as the slug
|
|
58
|
+
slug = project_name.replace("-", "_")
|
|
59
|
+
|
|
60
|
+
result_path = cookiecutter(
|
|
61
|
+
template=str(template_path),
|
|
62
|
+
no_input=True,
|
|
63
|
+
extra_context={
|
|
64
|
+
**context,
|
|
65
|
+
"project_slug": slug,
|
|
66
|
+
"project_name": project_name,
|
|
67
|
+
},
|
|
68
|
+
output_dir=output_dir,
|
|
69
|
+
overwrite_if_exists=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return Path(result_path)
|