fastango 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.
- fastango/__init__.py +3 -0
- fastango/cli.py +439 -0
- fastango/generator/__init__.py +1 -0
- fastango/generator/constraints.py +77 -0
- fastango/generator/model_catalog.py +180 -0
- fastango/generator/models.py +67 -0
- fastango/generator/planner.py +126 -0
- fastango/generator/preview.py +88 -0
- fastango/generator/providers/__init__.py +1 -0
- fastango/generator/providers/anthropic.py +53 -0
- fastango/generator/providers/base.py +48 -0
- fastango/generator/providers/openai.py +44 -0
- fastango/generator/rules.py +162 -0
- fastango/generator/skills.py +104 -0
- fastango/integrations/__init__.py +5 -0
- fastango/integrations/base.py +27 -0
- fastango/integrations/builtins.py +434 -0
- fastango/integrations/catalog.py +224 -0
- fastango/integrations/categories/__init__.py +1 -0
- fastango/integrations/categories/ai.py +94 -0
- fastango/integrations/categories/api.py +47 -0
- fastango/integrations/categories/auth.py +119 -0
- fastango/integrations/categories/cache_queue.py +65 -0
- fastango/integrations/categories/database.py +100 -0
- fastango/integrations/categories/deploy.py +117 -0
- fastango/integrations/categories/devtools.py +59 -0
- fastango/integrations/categories/observability.py +56 -0
- fastango/integrations/categories/payments.py +98 -0
- fastango/integrations/categories/product.py +195 -0
- fastango/integrations/categories/storage.py +59 -0
- fastango/py.typed +0 -0
- fastango/scaffold/__init__.py +6 -0
- fastango/scaffold/config.py +111 -0
- fastango/scaffold/engine.py +61 -0
- fastango/scaffold/filesystem.py +54 -0
- fastango/scaffold/plan.py +111 -0
- fastango/scaffold/preview.py +34 -0
- fastango/scaffold/prompts.py +67 -0
- fastango/scaffold/registry.py +116 -0
- fastango/scaffold/renderer.py +98 -0
- fastango/templates/__init__.py +1 -0
- fastango/templates/project/__init__.py +1 -0
- fastango/templates/project/mvc/.env.example.j2 +3 -0
- fastango/templates/project/mvc/.gitignore.j2 +8 -0
- fastango/templates/project/mvc/README.md.j2 +33 -0
- fastango/templates/project/mvc/app/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/api/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/api/deps.py.j2 +5 -0
- fastango/templates/project/mvc/app/api/routes/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/api/routes/health.py.j2 +18 -0
- fastango/templates/project/mvc/app/core/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/core/config.py.j2 +34 -0
- fastango/templates/project/mvc/app/core/logging.py.j2 +10 -0
- fastango/templates/project/mvc/app/main.py.j2 +46 -0
- fastango/templates/project/mvc/app/models/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/repositories/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/schemas/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/app/schemas/health.py.j2 +8 -0
- fastango/templates/project/mvc/app/services/__init__.py.j2 +1 -0
- fastango/templates/project/mvc/llms.txt.j2 +33 -0
- fastango/templates/project/mvc/pyproject.toml.j2 +30 -0
- fastango/templates/project/mvc/tests/test_health.py.j2 +12 -0
- fastango/templates/project/simple/.env.example.j2 +3 -0
- fastango/templates/project/simple/.gitignore.j2 +8 -0
- fastango/templates/project/simple/README.md.j2 +29 -0
- fastango/templates/project/simple/app/__init__.py.j2 +1 -0
- fastango/templates/project/simple/app/main.py.j2 +45 -0
- fastango/templates/project/simple/app/routes.py.j2 +18 -0
- fastango/templates/project/simple/app/schemas.py.j2 +8 -0
- fastango/templates/project/simple/app/settings.py.j2 +34 -0
- fastango/templates/project/simple/llms.txt.j2 +30 -0
- fastango/templates/project/simple/pyproject.toml.j2 +30 -0
- fastango/templates/project/simple/tests/test_health.py.j2 +12 -0
- fastango/terminal/__init__.py +1 -0
- fastango/terminal/models.py +27 -0
- fastango/terminal/tables.py +51 -0
- fastango/terminal/theme.py +36 -0
- fastango/tui/__init__.py +1 -0
- fastango/tui/app.py +169 -0
- fastango-0.1.0.dist-info/METADATA +260 -0
- fastango-0.1.0.dist-info/RECORD +84 -0
- fastango-0.1.0.dist-info/WHEEL +4 -0
- fastango-0.1.0.dist-info/entry_points.txt +2 -0
- fastango-0.1.0.dist-info/licenses/LICENSE +21 -0
fastango/__init__.py
ADDED
fastango/cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Command-line interface for Fastango."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.prompt import Confirm
|
|
11
|
+
|
|
12
|
+
from fastango import __version__
|
|
13
|
+
from fastango.generator.constraints import GenerationConstraintError
|
|
14
|
+
from fastango.generator.model_catalog import ModelProvider, available_models
|
|
15
|
+
from fastango.generator.models import GenerationProvider, GenerationRequest
|
|
16
|
+
from fastango.generator.planner import build_generation_plan
|
|
17
|
+
from fastango.generator.preview import (
|
|
18
|
+
generation_preview,
|
|
19
|
+
plan_to_json,
|
|
20
|
+
reasons_table,
|
|
21
|
+
security_table,
|
|
22
|
+
)
|
|
23
|
+
from fastango.generator.providers.base import ProviderError
|
|
24
|
+
from fastango.scaffold.config import ProjectConfig
|
|
25
|
+
from fastango.scaffold.engine import ScaffoldEngine
|
|
26
|
+
from fastango.scaffold.filesystem import ScaffoldWriteError
|
|
27
|
+
from fastango.scaffold.prompts import prompt_for_config
|
|
28
|
+
from fastango.scaffold.registry import IntegrationError, IntegrationRegistry
|
|
29
|
+
from fastango.terminal.models import models_table
|
|
30
|
+
from fastango.terminal.tables import integrations_table, presets_table
|
|
31
|
+
from fastango.terminal.theme import make_console
|
|
32
|
+
from fastango.tui.app import run_playground
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
help="Generate FastAPI projects with uv-first templates.",
|
|
36
|
+
invoke_without_command=True,
|
|
37
|
+
no_args_is_help=False,
|
|
38
|
+
)
|
|
39
|
+
console = make_console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def version_callback(value: bool) -> None:
|
|
43
|
+
if value:
|
|
44
|
+
console.print(f"fastango {__version__}")
|
|
45
|
+
raise typer.Exit
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def main(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
version: Annotated[
|
|
52
|
+
bool | None,
|
|
53
|
+
typer.Option("--version", callback=version_callback, help="Show Fastango version."),
|
|
54
|
+
] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Fastango CLI root command."""
|
|
57
|
+
|
|
58
|
+
if ctx.invoked_subcommand is None:
|
|
59
|
+
launch_playground(output_dir=Path.cwd(), dry_run=False, force=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def launch_playground(*, output_dir: Path, dry_run: bool, force: bool) -> None:
|
|
63
|
+
registry = IntegrationRegistry.builtins()
|
|
64
|
+
config = run_playground(registry=registry, output_dir=output_dir, force=force)
|
|
65
|
+
if config is None:
|
|
66
|
+
console.print("[fastango.muted]Cancelled.[/]")
|
|
67
|
+
raise typer.Exit
|
|
68
|
+
result = ScaffoldEngine(registry=registry).create(config, dry_run=dry_run)
|
|
69
|
+
if dry_run:
|
|
70
|
+
console.print(
|
|
71
|
+
f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
|
|
72
|
+
)
|
|
73
|
+
for path in result.files:
|
|
74
|
+
console.print(f" {path.relative_to(result.target_dir)}")
|
|
75
|
+
return
|
|
76
|
+
console.print(f"[fastango.success]Created FastAPI project at {result.target_dir}[/]")
|
|
77
|
+
console.print("\nNext steps:")
|
|
78
|
+
console.print(f" cd {result.target_dir}")
|
|
79
|
+
console.print(" uv sync")
|
|
80
|
+
console.print(" cp .env.example .env")
|
|
81
|
+
console.print(" uv run fastapi dev app/main.py")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("integrations")
|
|
85
|
+
def integrations(
|
|
86
|
+
category: Annotated[
|
|
87
|
+
str | None,
|
|
88
|
+
typer.Option("--category", "-c", help="Filter integrations by category."),
|
|
89
|
+
] = None,
|
|
90
|
+
search: Annotated[
|
|
91
|
+
str | None,
|
|
92
|
+
typer.Option("--search", "-s", help="Search integrations by name, tag, or description."),
|
|
93
|
+
] = None,
|
|
94
|
+
show_presets: Annotated[
|
|
95
|
+
bool,
|
|
96
|
+
typer.Option("--presets", help="Show curated presets instead of integrations."),
|
|
97
|
+
] = False,
|
|
98
|
+
json_output: Annotated[
|
|
99
|
+
bool,
|
|
100
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
101
|
+
] = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""List built-in integrations."""
|
|
104
|
+
|
|
105
|
+
registry = IntegrationRegistry.builtins()
|
|
106
|
+
if show_presets:
|
|
107
|
+
presets = registry.presets()
|
|
108
|
+
if json_output:
|
|
109
|
+
console.print_json(
|
|
110
|
+
json.dumps(
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
"name": preset.name,
|
|
114
|
+
"label": preset.label,
|
|
115
|
+
"description": preset.description,
|
|
116
|
+
"integrations": preset.integrations,
|
|
117
|
+
"tags": preset.tags,
|
|
118
|
+
}
|
|
119
|
+
for preset in presets
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
console.print(presets_table(presets))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
filtered = registry.list(category=category, search=search)
|
|
128
|
+
if json_output:
|
|
129
|
+
console.print_json(
|
|
130
|
+
json.dumps(
|
|
131
|
+
[
|
|
132
|
+
{
|
|
133
|
+
"name": integration.name,
|
|
134
|
+
"label": integration.label,
|
|
135
|
+
"category": integration.category,
|
|
136
|
+
"description": integration.description,
|
|
137
|
+
"tags": integration.tags,
|
|
138
|
+
"supports": integration.supports,
|
|
139
|
+
"requires": integration.requires,
|
|
140
|
+
"conflicts": integration.conflicts,
|
|
141
|
+
"aliases": integration.aliases,
|
|
142
|
+
"maturity": integration.maturity,
|
|
143
|
+
}
|
|
144
|
+
for integration in filtered
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
console.print(integrations_table(filtered))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def playground(
|
|
154
|
+
output_dir: Annotated[
|
|
155
|
+
Path | None,
|
|
156
|
+
typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
|
|
157
|
+
] = None,
|
|
158
|
+
dry_run: Annotated[
|
|
159
|
+
bool,
|
|
160
|
+
typer.Option("--dry-run", help="Preview the selected project without writing files."),
|
|
161
|
+
] = False,
|
|
162
|
+
force: Annotated[
|
|
163
|
+
bool,
|
|
164
|
+
typer.Option("--force", help="Overwrite files if they already exist."),
|
|
165
|
+
] = False,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Launch the branded interactive Fastango playground."""
|
|
168
|
+
|
|
169
|
+
launch_playground(output_dir=output_dir or Path.cwd(), dry_run=dry_run, force=force)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command("models")
|
|
173
|
+
def models(
|
|
174
|
+
provider: Annotated[
|
|
175
|
+
ModelProvider | None,
|
|
176
|
+
typer.Option("--provider", help="Filter models by provider: anthropic or openai."),
|
|
177
|
+
] = None,
|
|
178
|
+
json_output: Annotated[
|
|
179
|
+
bool,
|
|
180
|
+
typer.Option("--json", help="Print supported models as JSON."),
|
|
181
|
+
] = False,
|
|
182
|
+
static: Annotated[
|
|
183
|
+
bool,
|
|
184
|
+
typer.Option("--static", help="Show Fastango's curated offline model list."),
|
|
185
|
+
] = False,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""List AI generation models from API keys, with curated fallback."""
|
|
188
|
+
|
|
189
|
+
supported = available_models(provider, live=not static)
|
|
190
|
+
if json_output:
|
|
191
|
+
console.print_json(
|
|
192
|
+
json.dumps(
|
|
193
|
+
[
|
|
194
|
+
{
|
|
195
|
+
"provider": model.provider,
|
|
196
|
+
"model": model.model,
|
|
197
|
+
"label": model.label,
|
|
198
|
+
"description": model.description,
|
|
199
|
+
"default": model.default,
|
|
200
|
+
"source": model.source,
|
|
201
|
+
}
|
|
202
|
+
for model in supported
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
console.print(models_table(supported))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def generate(
|
|
212
|
+
prompt: Annotated[
|
|
213
|
+
str,
|
|
214
|
+
typer.Argument(help="Natural-language FastAPI project idea."),
|
|
215
|
+
],
|
|
216
|
+
provider: Annotated[
|
|
217
|
+
GenerationProvider,
|
|
218
|
+
typer.Option(
|
|
219
|
+
"--provider",
|
|
220
|
+
help="Generation provider: deterministic, anthropic, openai, or auto.",
|
|
221
|
+
),
|
|
222
|
+
] = "deterministic",
|
|
223
|
+
model: Annotated[
|
|
224
|
+
str | None,
|
|
225
|
+
typer.Option("--model", help="Supported model ID for Anthropic/OpenAI providers."),
|
|
226
|
+
] = None,
|
|
227
|
+
style: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
typer.Option("--style", help="Template style: simple or mvc."),
|
|
230
|
+
] = None,
|
|
231
|
+
project_name: Annotated[
|
|
232
|
+
str | None,
|
|
233
|
+
typer.Option("--project-name", help="Project name to use instead of inferring one."),
|
|
234
|
+
] = None,
|
|
235
|
+
output_dir: Annotated[
|
|
236
|
+
Path | None,
|
|
237
|
+
typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
|
|
238
|
+
] = None,
|
|
239
|
+
yes: Annotated[
|
|
240
|
+
bool,
|
|
241
|
+
typer.Option("--yes", "-y", help="Generate without an interactive confirmation."),
|
|
242
|
+
] = False,
|
|
243
|
+
dry_run: Annotated[
|
|
244
|
+
bool,
|
|
245
|
+
typer.Option("--dry-run", help="Preview files without writing them."),
|
|
246
|
+
] = False,
|
|
247
|
+
json_output: Annotated[
|
|
248
|
+
bool,
|
|
249
|
+
typer.Option("--json", help="Print the generation plan as JSON."),
|
|
250
|
+
] = False,
|
|
251
|
+
force: Annotated[
|
|
252
|
+
bool,
|
|
253
|
+
typer.Option("--force", help="Overwrite files if they already exist."),
|
|
254
|
+
] = False,
|
|
255
|
+
allow_experimental_suggestions: Annotated[
|
|
256
|
+
bool,
|
|
257
|
+
typer.Option(
|
|
258
|
+
"--allow-experimental-suggestions",
|
|
259
|
+
help="Keep unsupported provider suggestions as notes instead of failing.",
|
|
260
|
+
),
|
|
261
|
+
] = False,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Generate a constrained FastAPI project from a natural-language prompt."""
|
|
264
|
+
|
|
265
|
+
resolved_output_dir = output_dir or Path.cwd()
|
|
266
|
+
try:
|
|
267
|
+
request = GenerationRequest(
|
|
268
|
+
prompt=prompt,
|
|
269
|
+
provider=provider,
|
|
270
|
+
model=model,
|
|
271
|
+
style=style, # type: ignore[arg-type]
|
|
272
|
+
project_name=project_name,
|
|
273
|
+
output_dir=resolved_output_dir,
|
|
274
|
+
yes=yes,
|
|
275
|
+
dry_run=dry_run,
|
|
276
|
+
json_output=json_output,
|
|
277
|
+
force=force,
|
|
278
|
+
allow_experimental_suggestions=allow_experimental_suggestions,
|
|
279
|
+
)
|
|
280
|
+
plan = build_generation_plan(request)
|
|
281
|
+
except (GenerationConstraintError, IntegrationError, ProviderError, ValueError) as exc:
|
|
282
|
+
console.print(f"[fastango.error]Error:[/] {exc}")
|
|
283
|
+
raise typer.Exit(code=1) from exc
|
|
284
|
+
|
|
285
|
+
if json_output:
|
|
286
|
+
console.print_json(plan_to_json(plan))
|
|
287
|
+
if dry_run:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if not json_output:
|
|
291
|
+
console.print(generation_preview(plan, output_dir=resolved_output_dir, force=force))
|
|
292
|
+
console.print(reasons_table(plan))
|
|
293
|
+
console.print(security_table(plan))
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
not yes
|
|
297
|
+
and not dry_run
|
|
298
|
+
and not Confirm.ask(
|
|
299
|
+
"[fastango.title]Generate this constrained FastAPI project?[/]", default=True
|
|
300
|
+
)
|
|
301
|
+
):
|
|
302
|
+
console.print("[fastango.muted]Cancelled.[/]")
|
|
303
|
+
raise typer.Exit
|
|
304
|
+
|
|
305
|
+
config = plan.to_project_config(output_dir=resolved_output_dir, force=force)
|
|
306
|
+
result = ScaffoldEngine().create(config, dry_run=dry_run)
|
|
307
|
+
if dry_run:
|
|
308
|
+
console.print(
|
|
309
|
+
f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
|
|
310
|
+
)
|
|
311
|
+
for path in result.files:
|
|
312
|
+
console.print(f" {path.relative_to(result.target_dir)}")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
console.print(f"[fastango.success]Generated FastAPI project at {result.target_dir}[/]")
|
|
316
|
+
console.print("\nNext steps:")
|
|
317
|
+
console.print(f" cd {result.target_dir}")
|
|
318
|
+
console.print(" uv sync")
|
|
319
|
+
console.print(" cp .env.example .env")
|
|
320
|
+
console.print(" uv run fastapi dev app/main.py")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command()
|
|
324
|
+
def create(
|
|
325
|
+
project_name: Annotated[
|
|
326
|
+
str | None,
|
|
327
|
+
typer.Argument(help="Project name and destination directory."),
|
|
328
|
+
] = None,
|
|
329
|
+
package_name: Annotated[
|
|
330
|
+
str | None,
|
|
331
|
+
typer.Option("--package-name", help="Python package name for generated imports."),
|
|
332
|
+
] = None,
|
|
333
|
+
output_dir: Annotated[
|
|
334
|
+
Path | None,
|
|
335
|
+
typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
|
|
336
|
+
] = None,
|
|
337
|
+
style: Annotated[
|
|
338
|
+
str,
|
|
339
|
+
typer.Option("--style", help="Template style: simple or mvc."),
|
|
340
|
+
] = "simple",
|
|
341
|
+
python_version: Annotated[
|
|
342
|
+
str,
|
|
343
|
+
typer.Option("--python", help="Python version for the generated project."),
|
|
344
|
+
] = "3.12",
|
|
345
|
+
integration: Annotated[
|
|
346
|
+
list[str] | None,
|
|
347
|
+
typer.Option("--integration", "-i", help="Integration to include. Repeat this option."),
|
|
348
|
+
] = None,
|
|
349
|
+
preset: Annotated[
|
|
350
|
+
list[str] | None,
|
|
351
|
+
typer.Option("--preset", "-p", help="Curated preset to include. Repeat this option."),
|
|
352
|
+
] = None,
|
|
353
|
+
with_docker: Annotated[
|
|
354
|
+
bool,
|
|
355
|
+
typer.Option("--with-docker", help="Shortcut for --integration docker."),
|
|
356
|
+
] = False,
|
|
357
|
+
create_git: Annotated[
|
|
358
|
+
bool,
|
|
359
|
+
typer.Option(
|
|
360
|
+
"--git/--no-git", help="Initialize a Git repository in the generated project."
|
|
361
|
+
),
|
|
362
|
+
] = False,
|
|
363
|
+
interactive: Annotated[
|
|
364
|
+
bool,
|
|
365
|
+
typer.Option("--interactive/--no-interactive", help="Prompt for missing choices."),
|
|
366
|
+
] = True,
|
|
367
|
+
basic: Annotated[
|
|
368
|
+
bool,
|
|
369
|
+
typer.Option("--basic", help="Use basic prompts instead of the branded playground."),
|
|
370
|
+
] = False,
|
|
371
|
+
dry_run: Annotated[
|
|
372
|
+
bool,
|
|
373
|
+
typer.Option("--dry-run", help="Print files that would be generated without writing them."),
|
|
374
|
+
] = False,
|
|
375
|
+
force: Annotated[
|
|
376
|
+
bool,
|
|
377
|
+
typer.Option("--force", help="Overwrite files if they already exist."),
|
|
378
|
+
] = False,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Create a new FastAPI project."""
|
|
381
|
+
|
|
382
|
+
registry = IntegrationRegistry.builtins()
|
|
383
|
+
resolved_output_dir = output_dir or Path.cwd()
|
|
384
|
+
selected_integrations = list(integration or [])
|
|
385
|
+
if with_docker:
|
|
386
|
+
selected_integrations.append("docker")
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
if interactive and project_name is None and not basic:
|
|
390
|
+
config = run_playground(registry=registry, output_dir=resolved_output_dir, force=force)
|
|
391
|
+
if config is None:
|
|
392
|
+
console.print("[fastango.muted]Cancelled.[/]")
|
|
393
|
+
raise typer.Exit
|
|
394
|
+
elif interactive and project_name is None:
|
|
395
|
+
config = prompt_for_config(
|
|
396
|
+
project_name=project_name,
|
|
397
|
+
package_name=package_name,
|
|
398
|
+
output_dir=resolved_output_dir,
|
|
399
|
+
style=style,
|
|
400
|
+
python_version=python_version,
|
|
401
|
+
integrations=tuple(selected_integrations),
|
|
402
|
+
create_git=create_git,
|
|
403
|
+
force=force,
|
|
404
|
+
registry=registry,
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
if project_name is None:
|
|
408
|
+
raise typer.BadParameter("PROJECT_NAME is required when --no-interactive is used.")
|
|
409
|
+
config = ProjectConfig(
|
|
410
|
+
project_name=project_name,
|
|
411
|
+
package_name=package_name,
|
|
412
|
+
output_dir=resolved_output_dir,
|
|
413
|
+
style=style, # type: ignore[arg-type]
|
|
414
|
+
python_version=python_version,
|
|
415
|
+
integrations=tuple(selected_integrations),
|
|
416
|
+
presets=tuple(preset or []),
|
|
417
|
+
create_git=create_git,
|
|
418
|
+
force=force,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
result = ScaffoldEngine(registry=registry).create(config, dry_run=dry_run)
|
|
422
|
+
except (IntegrationError, ScaffoldWriteError, ValueError) as exc:
|
|
423
|
+
console.print(f"[fastango.error]Error:[/] {exc}")
|
|
424
|
+
raise typer.Exit(code=1) from exc
|
|
425
|
+
|
|
426
|
+
if dry_run:
|
|
427
|
+
console.print(
|
|
428
|
+
f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
|
|
429
|
+
)
|
|
430
|
+
for path in result.files:
|
|
431
|
+
console.print(f" {path.relative_to(result.target_dir)}")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
console.print(f"[fastango.success]Created FastAPI project at {result.target_dir}[/]")
|
|
435
|
+
console.print("\nNext steps:")
|
|
436
|
+
console.print(f" cd {result.target_dir}")
|
|
437
|
+
console.print(" uv sync")
|
|
438
|
+
console.print(" cp .env.example .env")
|
|
439
|
+
console.print(" uv run fastapi dev app/main.py")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Constrained prompt-to-project generation for Fastango."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Allowlist validation for generated Fastango plans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastango.generator.models import GenerationPlan
|
|
9
|
+
from fastango.generator.skills import skill_names
|
|
10
|
+
from fastango.scaffold.registry import IntegrationError, IntegrationRegistry
|
|
11
|
+
|
|
12
|
+
SUPPORTED_TEMPLATES = ("simple", "mvc")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GenerationConstraintError(ValueError):
|
|
16
|
+
"""Raised when a generated plan escapes Fastango's supported surface."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ProviderSuggestion:
|
|
21
|
+
skills: tuple[str, ...] = ()
|
|
22
|
+
presets: tuple[str, ...] = ()
|
|
23
|
+
integrations: tuple[str, ...] = ()
|
|
24
|
+
style: str | None = None
|
|
25
|
+
raw_code: str | None = None
|
|
26
|
+
notes: tuple[str, ...] = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_provider_suggestion(
|
|
30
|
+
suggestion: ProviderSuggestion,
|
|
31
|
+
registry: IntegrationRegistry,
|
|
32
|
+
*,
|
|
33
|
+
allow_experimental_suggestions: bool = False,
|
|
34
|
+
) -> tuple[str, ...]:
|
|
35
|
+
"""Validate provider output and return unsupported notes."""
|
|
36
|
+
|
|
37
|
+
unsupported: list[str] = []
|
|
38
|
+
if suggestion.raw_code:
|
|
39
|
+
raise GenerationConstraintError("Provider output included raw code, which is not allowed.")
|
|
40
|
+
if suggestion.style and suggestion.style not in SUPPORTED_TEMPLATES:
|
|
41
|
+
raise GenerationConstraintError(f"Unsupported template '{suggestion.style}'.")
|
|
42
|
+
for skill in suggestion.skills:
|
|
43
|
+
if skill not in skill_names():
|
|
44
|
+
if allow_experimental_suggestions:
|
|
45
|
+
unsupported.append(f"Not generated: unsupported skill '{skill}'.")
|
|
46
|
+
else:
|
|
47
|
+
raise GenerationConstraintError(f"Unsupported skill '{skill}'.")
|
|
48
|
+
for preset in suggestion.presets:
|
|
49
|
+
try:
|
|
50
|
+
registry.get_preset(preset)
|
|
51
|
+
except IntegrationError as exc:
|
|
52
|
+
if allow_experimental_suggestions:
|
|
53
|
+
unsupported.append(f"Not generated: unsupported preset '{preset}'.")
|
|
54
|
+
else:
|
|
55
|
+
raise GenerationConstraintError(str(exc)) from exc
|
|
56
|
+
for integration in suggestion.integrations:
|
|
57
|
+
try:
|
|
58
|
+
registry.get(integration)
|
|
59
|
+
except IntegrationError as exc:
|
|
60
|
+
if allow_experimental_suggestions:
|
|
61
|
+
unsupported.append(f"Not generated: unsupported integration '{integration}'.")
|
|
62
|
+
else:
|
|
63
|
+
raise GenerationConstraintError(str(exc)) from exc
|
|
64
|
+
return tuple(unsupported)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_generation_plan(plan: GenerationPlan, registry: IntegrationRegistry) -> None:
|
|
68
|
+
if plan.style not in SUPPORTED_TEMPLATES:
|
|
69
|
+
raise GenerationConstraintError(f"Unsupported template '{plan.style}'.")
|
|
70
|
+
for skill in plan.skills:
|
|
71
|
+
if skill not in skill_names():
|
|
72
|
+
raise GenerationConstraintError(f"Unsupported skill '{skill}'.")
|
|
73
|
+
for preset in plan.presets:
|
|
74
|
+
registry.get_preset(preset)
|
|
75
|
+
for integration in plan.integrations:
|
|
76
|
+
registry.get(integration)
|
|
77
|
+
registry.resolve(plan.to_project_config(output_dir=Path(".")))
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Supported LLM model catalog for optional generation providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from fastango.generator.providers.base import ProviderError
|
|
12
|
+
|
|
13
|
+
ModelProvider = Literal["anthropic", "openai"]
|
|
14
|
+
ModelSource = Literal["curated", "api"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SupportedModel:
|
|
19
|
+
provider: ModelProvider
|
|
20
|
+
model: str
|
|
21
|
+
label: str
|
|
22
|
+
description: str
|
|
23
|
+
default: bool = False
|
|
24
|
+
source: ModelSource = "curated"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
SUPPORTED_MODELS: tuple[SupportedModel, ...] = (
|
|
28
|
+
SupportedModel(
|
|
29
|
+
provider="anthropic",
|
|
30
|
+
model="claude-3-5-haiku-latest",
|
|
31
|
+
label="Claude 3.5 Haiku",
|
|
32
|
+
description="Fast and cost-conscious planning for normal scaffolds.",
|
|
33
|
+
default=True,
|
|
34
|
+
),
|
|
35
|
+
SupportedModel(
|
|
36
|
+
provider="anthropic",
|
|
37
|
+
model="claude-3-5-sonnet-latest",
|
|
38
|
+
label="Claude 3.5 Sonnet",
|
|
39
|
+
description="Stronger reasoning for larger MVP prompts.",
|
|
40
|
+
),
|
|
41
|
+
SupportedModel(
|
|
42
|
+
provider="anthropic",
|
|
43
|
+
model="claude-3-7-sonnet-latest",
|
|
44
|
+
label="Claude 3.7 Sonnet",
|
|
45
|
+
description="Advanced planning for complex product scaffolds.",
|
|
46
|
+
),
|
|
47
|
+
SupportedModel(
|
|
48
|
+
provider="openai",
|
|
49
|
+
model="gpt-4.1-mini",
|
|
50
|
+
label="GPT-4.1 mini",
|
|
51
|
+
description="Fast and cost-conscious planning for normal scaffolds.",
|
|
52
|
+
default=True,
|
|
53
|
+
),
|
|
54
|
+
SupportedModel(
|
|
55
|
+
provider="openai",
|
|
56
|
+
model="gpt-4.1",
|
|
57
|
+
label="GPT-4.1",
|
|
58
|
+
description="Stronger planning for more complex MVP prompts.",
|
|
59
|
+
),
|
|
60
|
+
SupportedModel(
|
|
61
|
+
provider="openai",
|
|
62
|
+
model="gpt-4o-mini",
|
|
63
|
+
label="GPT-4o mini",
|
|
64
|
+
description="General-purpose planning with broad availability.",
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def models_for_provider(provider: ModelProvider | None = None) -> tuple[SupportedModel, ...]:
|
|
70
|
+
if provider is None:
|
|
71
|
+
return SUPPORTED_MODELS
|
|
72
|
+
return tuple(model for model in SUPPORTED_MODELS if model.provider == provider)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def models_from_api(provider: ModelProvider) -> tuple[SupportedModel, ...]:
|
|
76
|
+
if provider == "anthropic":
|
|
77
|
+
return _anthropic_models_from_api()
|
|
78
|
+
return _openai_models_from_api()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def available_models(
|
|
82
|
+
provider: ModelProvider | None = None,
|
|
83
|
+
*,
|
|
84
|
+
live: bool = True,
|
|
85
|
+
) -> tuple[SupportedModel, ...]:
|
|
86
|
+
if not live:
|
|
87
|
+
return models_for_provider(provider)
|
|
88
|
+
if provider is not None:
|
|
89
|
+
try:
|
|
90
|
+
return models_from_api(provider)
|
|
91
|
+
except ProviderError:
|
|
92
|
+
return models_for_provider(provider)
|
|
93
|
+
|
|
94
|
+
models: list[SupportedModel] = []
|
|
95
|
+
for candidate in ("anthropic", "openai"):
|
|
96
|
+
try:
|
|
97
|
+
models.extend(models_from_api(candidate))
|
|
98
|
+
except ProviderError:
|
|
99
|
+
models.extend(models_for_provider(candidate))
|
|
100
|
+
return tuple(models)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def default_model(provider: ModelProvider) -> str:
|
|
104
|
+
for model in SUPPORTED_MODELS:
|
|
105
|
+
if model.provider == provider and model.default:
|
|
106
|
+
return model.model
|
|
107
|
+
return models_for_provider(provider)[0].model
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_model(provider: ModelProvider, model: str | None) -> str:
|
|
111
|
+
selected = model or default_model(provider)
|
|
112
|
+
supported = {item.model for item in models_for_provider(provider)}
|
|
113
|
+
if selected not in supported:
|
|
114
|
+
with suppress(ProviderError):
|
|
115
|
+
supported.update(item.model for item in models_from_api(provider))
|
|
116
|
+
if selected not in supported:
|
|
117
|
+
available = ", ".join(sorted(supported))
|
|
118
|
+
raise ProviderError(
|
|
119
|
+
f"Model '{selected}' is not supported for provider '{provider}'. Available: {available}."
|
|
120
|
+
)
|
|
121
|
+
return selected
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _httpx() -> Any:
|
|
125
|
+
try:
|
|
126
|
+
return import_module("httpx")
|
|
127
|
+
except ImportError as exc: # pragma: no cover - optional dependency path.
|
|
128
|
+
raise ProviderError(
|
|
129
|
+
"Install Fastango with the `ai` extra to discover provider models."
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _anthropic_models_from_api() -> tuple[SupportedModel, ...]:
|
|
134
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
135
|
+
if not api_key:
|
|
136
|
+
raise ProviderError("ANTHROPIC_API_KEY is required to discover Anthropic models.")
|
|
137
|
+
response = _httpx().get(
|
|
138
|
+
"https://api.anthropic.com/v1/models",
|
|
139
|
+
headers={
|
|
140
|
+
"x-api-key": api_key,
|
|
141
|
+
"anthropic-version": "2023-06-01",
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
},
|
|
144
|
+
timeout=30,
|
|
145
|
+
)
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
return tuple(
|
|
148
|
+
SupportedModel(
|
|
149
|
+
provider="anthropic",
|
|
150
|
+
model=str(item["id"]),
|
|
151
|
+
label=str(item.get("display_name") or item["id"]),
|
|
152
|
+
description="Discovered from the configured Anthropic API key.",
|
|
153
|
+
source="api",
|
|
154
|
+
)
|
|
155
|
+
for item in response.json().get("data", [])
|
|
156
|
+
if item.get("id")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _openai_models_from_api() -> tuple[SupportedModel, ...]:
|
|
161
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
162
|
+
if not api_key:
|
|
163
|
+
raise ProviderError("OPENAI_API_KEY is required to discover OpenAI models.")
|
|
164
|
+
response = _httpx().get(
|
|
165
|
+
"https://api.openai.com/v1/models",
|
|
166
|
+
headers={"authorization": f"Bearer {api_key}", "content-type": "application/json"},
|
|
167
|
+
timeout=30,
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
return tuple(
|
|
171
|
+
SupportedModel(
|
|
172
|
+
provider="openai",
|
|
173
|
+
model=str(item["id"]),
|
|
174
|
+
label=str(item["id"]),
|
|
175
|
+
description="Discovered from the configured OpenAI API key.",
|
|
176
|
+
source="api",
|
|
177
|
+
)
|
|
178
|
+
for item in response.json().get("data", [])
|
|
179
|
+
if item.get("id")
|
|
180
|
+
)
|