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.
Files changed (80) hide show
  1. lambda_forge_cli_aws-1.0.0.dist-info/METADATA +352 -0
  2. lambda_forge_cli_aws-1.0.0.dist-info/RECORD +80 -0
  3. lambda_forge_cli_aws-1.0.0.dist-info/WHEEL +4 -0
  4. lambda_forge_cli_aws-1.0.0.dist-info/entry_points.txt +2 -0
  5. lambda_forge_cli_aws-1.0.0.dist-info/licenses/LICENSE +189 -0
  6. lambdaforge/__init__.py +10 -0
  7. lambdaforge/bedrock_client.py +109 -0
  8. lambdaforge/cli.py +428 -0
  9. lambdaforge/deploy.py +308 -0
  10. lambdaforge/logs.py +185 -0
  11. lambdaforge/renderer.py +72 -0
  12. lambdaforge/spec_generator.py +171 -0
  13. lambdaforge/status.py +111 -0
  14. lambdaforge/templates.py +106 -0
  15. lambdaforge/upgrade.py +231 -0
  16. templates/python-api/cookiecutter.json +12 -0
  17. templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/dependabot.yml +22 -0
  18. templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/deploy.yml +90 -0
  19. templates/python-api/{{cookiecutter.project_slug}}/.gitignore +65 -0
  20. templates/python-api/{{cookiecutter.project_slug}}/Dockerfile +11 -0
  21. templates/python-api/{{cookiecutter.project_slug}}/README.md +130 -0
  22. templates/python-api/{{cookiecutter.project_slug}}/infrastructure/app.js +26 -0
  23. templates/python-api/{{cookiecutter.project_slug}}/infrastructure/cdk.json +20 -0
  24. templates/python-api/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +155 -0
  25. templates/python-api/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
  26. templates/python-api/{{cookiecutter.project_slug}}/pyproject.toml +66 -0
  27. templates/python-api/{{cookiecutter.project_slug}}/requirements-dev.txt +8 -0
  28. templates/python-api/{{cookiecutter.project_slug}}/requirements.txt +3 -0
  29. templates/python-api/{{cookiecutter.project_slug}}/src/handler.py +93 -0
  30. templates/python-api/{{cookiecutter.project_slug}}/src/utils/logger.py +39 -0
  31. templates/python-api/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
  32. templates/python-api/{{cookiecutter.project_slug}}/tests/test_handler.py +68 -0
  33. templates/python-cron/cookiecutter.json +13 -0
  34. templates/python-cron/{{cookiecutter.project_slug}}/.gitignore +65 -0
  35. templates/python-cron/{{cookiecutter.project_slug}}/Dockerfile +11 -0
  36. templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
  37. templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
  38. templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +55 -0
  39. templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
  40. templates/python-cron/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
  41. templates/python-cron/{{cookiecutter.project_slug}}/requirements.txt +2 -0
  42. templates/python-cron/{{cookiecutter.project_slug}}/src/handler.py +53 -0
  43. templates/python-cron/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
  44. templates/python-cron/{{cookiecutter.project_slug}}/tests/test_handler.py +48 -0
  45. templates/python-queue/cookiecutter.json +12 -0
  46. templates/python-queue/{{cookiecutter.project_slug}}/.gitignore +65 -0
  47. templates/python-queue/{{cookiecutter.project_slug}}/Dockerfile +11 -0
  48. templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
  49. templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
  50. templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +79 -0
  51. templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
  52. templates/python-queue/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
  53. templates/python-queue/{{cookiecutter.project_slug}}/requirements.txt +2 -0
  54. templates/python-queue/{{cookiecutter.project_slug}}/src/handler.py +64 -0
  55. templates/python-queue/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
  56. templates/python-queue/{{cookiecutter.project_slug}}/tests/test_handler.py +60 -0
  57. templates/python-s3/cookiecutter.json +13 -0
  58. templates/python-s3/{{cookiecutter.project_slug}}/.gitignore +65 -0
  59. templates/python-s3/{{cookiecutter.project_slug}}/Dockerfile +11 -0
  60. templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
  61. templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
  62. templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +89 -0
  63. templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
  64. templates/python-s3/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
  65. templates/python-s3/{{cookiecutter.project_slug}}/requirements.txt +2 -0
  66. templates/python-s3/{{cookiecutter.project_slug}}/src/handler.py +86 -0
  67. templates/python-s3/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
  68. templates/python-s3/{{cookiecutter.project_slug}}/tests/test_handler.py +71 -0
  69. templates/python-stream/cookiecutter.json +12 -0
  70. templates/python-stream/{{cookiecutter.project_slug}}/.gitignore +65 -0
  71. templates/python-stream/{{cookiecutter.project_slug}}/Dockerfile +11 -0
  72. templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
  73. templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
  74. templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +81 -0
  75. templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
  76. templates/python-stream/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
  77. templates/python-stream/{{cookiecutter.project_slug}}/requirements.txt +2 -0
  78. templates/python-stream/{{cookiecutter.project_slug}}/src/handler.py +69 -0
  79. templates/python-stream/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
  80. 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)
@@ -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)