Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,16 @@
@click.version_option(version="0.1.0", prog_name="faststack")
def cli() -> None:
"""FastStack — Hybrid FastAPI framework + CLI generator."""
pass


# Alias so submodules can import the Click group without name collision
# with the ``cli`` package itself.
cli_group = cli


def _register_commands() -> None:
"""Import subcommands to register them with the CLI group."""
import cli.cmd_init # noqa: F401


_register_commands()
112 changes: 112 additions & 0 deletions cli/cmd_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""faststack init — scaffold a new FastStack project."""

from pathlib import Path

import click
from jinja2 import Environment, FileSystemLoader

from cli import cli_group
from cli.yaml_parser import parse_entities_yaml

TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "project"


@cli_group.command("init")
@click.argument("project_name")
@click.option("--entities", type=click.Path(exists=True), help="Path to entities.yaml")
def init_project(project_name: str, entities: str | None = None) -> None:
"""Scaffold a new FastStack project."""

project_dir = Path.cwd() / project_name
if project_dir.exists():
raise click.ClickException(f"Directory '{project_name}' already exists")

# Parse entities if YAML provided
entity_defs = []
if entities:
entity_defs = parse_entities_yaml(Path(entities))

# Create directory structure
dirs = [
"app",
"app/models",
"app/schemas",
"app/repositories",
"app/services",
"app/api",
"app/api/routes",
"alembic",
"alembic/versions",
"tests",
"tests/unit",
"tests/unit/fakes",
"tests/integration",
"tests/factories",
]
for d in dirs:
(project_dir / d).mkdir(parents=True, exist_ok=True)

# Create __init__.py files
init_dirs = [
"app",
"app/models",
"app/schemas",
"app/repositories",
"app/services",
"app/api",
"app/api/routes",
"tests",
"tests/unit",
"tests/unit/fakes",
"tests/integration",
"tests/factories",
]
for d in init_dirs:
(project_dir / d / "__init__.py").write_text("")

# Render templates
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), keep_trailing_newline=True)

template_context = {
"project_name": project_name,
"entities": entity_defs,
}

file_mappings = {
"pyproject.toml.j2": "pyproject.toml",
"main.py.j2": "app/main.py",
"config.py.j2": "app/config.py",
"conftest.py.j2": "tests/conftest.py",
"Dockerfile.j2": "Dockerfile",
"docker-compose.yml.j2": "docker-compose.yml",
"alembic.ini.j2": "alembic.ini",
"alembic_env.py.j2": "alembic/env.py",
}

for template_name, output_path in file_mappings.items():
template = env.get_template(template_name)
content = template.render(**template_context)
(project_dir / output_path).write_text(content)

# Create .project-config.yaml
config_content = f"project_name: {project_name}\narchitecture: simple\nentities: {{}}\n"
(project_dir / ".project-config.yaml").write_text(config_content)

# Create .env file
db_name = project_name.lower().replace("-", "_")
env_content = (
f"DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/{db_name}\n"
f"LOG_LEVEL=INFO\n"
)
(project_dir / ".env").write_text(env_content)

click.echo(f"Created project '{project_name}' at {project_dir}")
click.echo()
click.echo("Next steps:")
click.echo(f" cd {project_name}")
click.echo(" poetry install")
if entity_defs:
click.echo(' faststack migrate generate "initial"')
click.echo(" faststack migrate upgrade")
else:
click.echo(" faststack add-entity YourEntity --fields 'name:string:required'")
14 changes: 14 additions & 0 deletions templates/project/Dockerfile.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM python:3.12-slim

WORKDIR /app

RUN pip install poetry && poetry config virtualenvs.create false

COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-dev --no-interaction --no-ansi

COPY . .

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
36 changes: 36 additions & 0 deletions templates/project/alembic.ini.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = will_be_overridden_by_env_py

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
48 changes: 48 additions & 0 deletions templates/project/alembic_env.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Alembic environment — async engine with auto-model discovery."""

import asyncio
import importlib
import pkgutil
from logging.config import fileConfig

from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine

from app.config import settings
from faststack_core.base.entity import Base

# Auto-discover all model modules so Alembic sees them
import app.models
for _importer, modname, _ispkg in pkgutil.iter_modules(app.models.__path__):
importlib.import_module(f"app.models.{modname}")

config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline() -> None:
context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()


def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


async def run_migrations_online() -> None:
engine = create_async_engine(settings.database_url)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()


if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
19 changes: 19 additions & 0 deletions templates/project/config.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Application settings loaded from environment variables."""

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
"""Application configuration.

Values are read from environment variables or a .env file.
"""

app_name: str = "{{ project_name }}"
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/{{ project_name | lower | replace('-', '_') }}"
log_level: str = "INFO"

model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}


settings = Settings()
24 changes: 24 additions & 0 deletions templates/project/conftest.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Root test configuration with async database fixtures."""

import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from faststack_core.base.entity import Base

TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"


@pytest.fixture
async def async_engine():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()


@pytest.fixture
async def session(async_engine):
factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as sess:
yield sess
23 changes: 23 additions & 0 deletions templates/project/docker-compose.yml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: {{ project_name | lower | replace('-', '_') }}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data

app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/{{ project_name | lower | replace('-', '_') }}
depends_on:
- db

volumes:
pgdata:
16 changes: 16 additions & 0 deletions templates/project/main.py.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""{{ project_name }} — FastAPI application."""

from fastapi import FastAPI
from faststack_core.settings.config import FastStackConfig
from faststack_core.setup import setup_app

app = FastAPI(title="{{ project_name }}")

setup_app(app, FastStackConfig(
app_version="0.1.0",
))

{% for entity in entities %}
from app.api.routes.{{ entity.name | lower }} import router as {{ entity.name | lower }}_router
app.include_router({{ entity.name | lower }}_router, prefix="/api")
{% endfor %}
35 changes: 35 additions & 0 deletions templates/project/pyproject.toml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[tool.poetry]
name = "{{ project_name }}"
version = "0.1.0"
description = ""
authors = []
packages = [{ include = "app" }]

[tool.poetry.dependencies]
python = ">=3.12,<4.0"
faststack = ">=0.1.0"

[tool.poetry.group.dev.dependencies]
pytest = ">=8.0"
pytest-asyncio = ">=0.24.0"
polyfactory = ">=2.0"
httpx = ">=0.28.0"
aiosqlite = ">=0.20.0"
ruff = ">=0.8.0"
black = ">=24.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
target-version = "py312"
line-length = 100

[tool.black]
line-length = 100
target-version = ["py312"]
Loading
Loading