diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..bc7905e
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,4 @@
+BLITZ_PORT=8100
+DEFAULT_FILE="blitz.json"
+BLITZ_DB_TYPE="MEMORY"
+BLITZ_OPENAI_API_KEY=""
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..567f162
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: documentation
+on:
+ push:
+ branches:
+ - main
+permissions:
+ contents: write
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Configure Git Credentials
+ run: |
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ - uses: actions/setup-python@v4
+ with:
+ python-version: 3.x
+ - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
+ - uses: actions/cache@v3
+ with:
+ key: mkdocs-material-${{ env.cache_id }}
+ path: .cache
+ restore-keys: |
+ mkdocs-material-
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ - run: poetry install --with=doc
+ - run: poetry run mkdocs gh-deploy --force
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..56c2ad3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,168 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+app.db
+database.db
+
+*migration.py
+.python-version
+.DS_Store
+.nicegui/
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..8ab70c0
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1 @@
+MIT
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec48c37
--- /dev/null
+++ b/README.md
@@ -0,0 +1,81 @@
+#
+
+![alt text](./docs/images/blitz_banner.png)
+
+ ⚡️ Lightspeed API builder ⚡️
+
+
+![app version](https://img.shields.io/badge/version-0.1.0-brightgreen)
+![app license](https://img.shields.io/badge/license-MIT-brightgreen)
+
+> [!CAUTION]
+> Do not use in production, this is an alpha version.
+
+Full [Documentation](https://paperz-org.github.io/blitz/) here.
+
+# **What is Blitz ?**
+Blitz is a tool that build restfull API on the fly based on a simple and easy to maintain configuration file.
+
+Here is an example of how simple a Blitz file is:
+ ```yaml
+ config:
+ name: Hello world
+ description: Here is a simple blitz configuration file.
+ version: 0.1.0
+ models:
+ - name: TodoList
+ fields:
+ name: str
+ description: str
+ - name: Todo
+ fields:
+ name: str
+ due_date: str
+ todo_list_id: TodoList.id
+ ```
+> [!NOTE]
+> Also available in Json format.
+
+# Quickstart
+
+## Installation
+
+### Using [pipx](https://pipx.pypa.io/stable/installation/) (recommanded)
+```bash
+pipx install git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
+
+### Using pip
+```bash
+pip install --user git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
+
+## Create a blitz app
+
+```console
+blitz create your-blitz-app
+```
+
+## Start your blitz app
+
+```console
+blitz start your-blitz-app
+```
+
+*And yeah, that's it.*
+
+Just add some resources in the blitz file, and you have now a fully functional API with models and the corresponding database schema with all the modern feature you can expect from a modern app like:
+
+- Automatic Swagger UI for the API
+- Admin
+- Dashboard
+- Data validation and error messages (thanks to Fastapi and SQLModel)
+- Automatic database migration based on the changes of your blitz file
+- Generated ERD diagram
+- and more...
+
+
+
+
diff --git a/blitz/__init__.py b/blitz/__init__.py
new file mode 100644
index 0000000..490b7f3
--- /dev/null
+++ b/blitz/__init__.py
@@ -0,0 +1,5 @@
+from .core import BlitzCore
+
+__all__ = [
+ "BlitzCore",
+]
diff --git a/blitz/alembic/README b/blitz/alembic/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/blitz/alembic/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/blitz/alembic/env.py b/blitz/alembic/env.py
new file mode 100644
index 0000000..e411149
--- /dev/null
+++ b/blitz/alembic/env.py
@@ -0,0 +1,81 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+from blitz.models import BaseResourceModel
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = BaseResourceModel.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = config.attributes.get("connection", None)
+
+ if connectable is None:
+ # only create Engine if we don't have a Connection
+ # from the outside
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+ else:
+ context.configure(connection=connectable, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/blitz/alembic/script.py.mako b/blitz/alembic/script.py.mako
new file mode 100644
index 0000000..6ce3351
--- /dev/null
+++ b/blitz/alembic/script.py.mako
@@ -0,0 +1,27 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/blitz/api/__init__.py b/blitz/api/__init__.py
new file mode 100644
index 0000000..7d019a6
--- /dev/null
+++ b/blitz/api/__init__.py
@@ -0,0 +1,3 @@
+from .blitz_api import create_blitz_api
+
+__all__ = ["create_blitz_api"]
diff --git a/blitz/api/blitz_admin.py b/blitz/api/blitz_admin.py
new file mode 100644
index 0000000..e491d83
--- /dev/null
+++ b/blitz/api/blitz_admin.py
@@ -0,0 +1,38 @@
+from fastapi import FastAPI
+from blitz.db.db import get_sqlite_engine
+from starlette_admin.contrib.sqla import Admin, ModelView
+from typing import TYPE_CHECKING
+from starlette_admin.views import Link
+
+from blitz.settings import Settings, get_settings
+
+if TYPE_CHECKING:
+ from blitz.app import BlitzApp
+
+
+class BlitzAdmin:
+ def __init__(
+ self, blitz_app: "BlitzApp", settings: Settings = get_settings()
+ ) -> None:
+ self.blitz_app = blitz_app
+ self.admin = Admin(
+ title=f"{blitz_app.name} Admin",
+ # FIXME find a better way to get the engine
+ engine=get_sqlite_engine(
+ blitz_app, in_memory=blitz_app._in_memory, file_name="app.db"
+ ),
+ base_url=f"/admin/",
+ )
+ for resource in blitz_app.resources:
+ self.admin.add_view(ModelView(resource.model))
+
+ self.admin.add_view(
+ Link(
+ label="Go Back to Dashboard",
+ icon="fa fa-link",
+ url=f"http://localhost:{settings.BLITZ_PORT}/dashboard/projects/{self.blitz_app.name}",
+ )
+ )
+
+ def mount_to(self, app: FastAPI) -> None:
+ self.admin.mount_to(app)
diff --git a/blitz/api/blitz_api.py b/blitz/api/blitz_api.py
new file mode 100644
index 0000000..5a4a9c5
--- /dev/null
+++ b/blitz/api/blitz_api.py
@@ -0,0 +1,202 @@
+from functools import partial
+import os
+from typing import Any
+
+import warnings
+
+from fastapi.responses import RedirectResponse
+from blitz.api.blitz_admin import BlitzAdmin
+from blitz.core import BlitzCore
+from blitz.db.db import get_db
+from blitz.db.errors import NoChangesDetectedError
+from blitz.app import BlitzApp
+from fastapi import FastAPI, APIRouter
+from fastapi_crudrouter.core import CRUDGenerator # type: ignore
+from fastapi_crudrouter import SQLAlchemyCRUDRouter # type: ignore
+from semver import Version
+from blitz.models.blitz.resource import BlitzResource
+from blitz.patch import patch_fastapi_crudrouter
+from blitz.settings import DBTypes, get_settings
+from blitz.db.migrations import generate_migration, run_migrations
+from blitz.ui.main import init_ui
+from blitz.api.logs import configure as configure_logs
+from rich import print
+
+from sqlalchemy.exc import SAWarning
+
+# Patch the crudrouter to work with pydantic v2
+patch_fastapi_crudrouter()
+
+
+class BlitzAPI(FastAPI):
+ def __init__(
+ self,
+ blitz_app: BlitzApp,
+ enable_config_route: bool = True,
+ *args: Any,
+ docs_url: str = "/api/docs",
+ redoc_url: str = "/api/redoc",
+ **kwargs: Any,
+ ) -> None:
+ super().__init__(*args, docs_url=docs_url, redoc_url=redoc_url, **kwargs)
+ self.blitz_app = blitz_app
+ self.blitz_app.load()
+ self.logger = self.blitz_app.logger
+
+ if enable_config_route:
+ self.include_router(self._create_blitz_config_router())
+
+ for resource in self.blitz_app.resources:
+ router = self._create_crud_router(resource=resource)
+ self.include_router(router, prefix="/api")
+
+ self._add_healthcheck()
+ self._add_redirect()
+
+ configure_logs(self)
+
+ def _add_healthcheck(self) -> None:
+ self.router.add_api_route(
+ "/api",
+ lambda: {"status": "ok"},
+ methods=["GET"],
+ tags=["health"],
+ include_in_schema=False,
+ )
+
+ def _add_redirect(self) -> None:
+ self.router.add_api_route(
+ "/",
+ lambda: RedirectResponse(f"/dashboard/projects/{self.blitz_app.name}"),
+ methods=["GET"],
+ include_in_schema=False,
+ )
+
+ def _create_blitz_config_router(self) -> APIRouter:
+ path = "/blitz-config"
+ router = APIRouter()
+ router.add_api_route(
+ path=path,
+ endpoint=lambda: self.blitz_app.file.model_dump(by_alias=True),
+ methods=["GET"],
+ summary="Get Blitz Config",
+ description="Returns the Blitz Config for all resources",
+ )
+ return router
+
+ def _create_crud_router(self, resource: BlitzResource) -> CRUDGenerator:
+ read_model = resource.model.read_model()
+ create_model = resource.model.create_model()
+ update_model = resource.model.update_model()
+
+ # Rebuild the model to include forward ref types that was not available at the time of the model creation
+ # We need to use the model AFTER the rebuild because if not, all the relationship and cie will not be set
+ # correctly.
+ types_namespace = {resource.model.__name__: resource.model for resource in self.blitz_app.resources}
+ read_model.model_rebuild(_types_namespace=types_namespace)
+ create_model.model_rebuild(_types_namespace=types_namespace)
+ update_model.model_rebuild(_types_namespace=types_namespace)
+ resource.model.model_rebuild(_types_namespace=types_namespace)
+
+ return SQLAlchemyCRUDRouter(
+ schema=read_model,
+ create_schema=create_model,
+ update_schema=update_model,
+ db_model=resource.model,
+ db=partial(
+ get_db,
+ self.blitz_app,
+ get_settings().BLITZ_DB_TYPE == DBTypes.MEMORY,
+ "app.db",
+ ),
+ delete_all_route=False,
+ get_all_route=resource.config.can_read,
+ get_one_route=resource.config.can_read,
+ update_route=resource.config.can_update,
+ create_route=resource.config.can_create,
+ delete_one_route=resource.config.can_delete,
+ )
+
+
+def create_blitz_api(
+ blitz_app: BlitzApp | None = None,
+ enable_config_route: bool = True,
+ admin: bool = True,
+ *args: Any,
+ docs_url: str = "/api/docs",
+ redoc_url: str = "/api/redoc",
+ **kwargs: Any,
+) -> BlitzAPI:
+
+ if blitz_app is None:
+ blitz = BlitzCore()
+ blitz_app_name = os.getenv("BLITZ_APP")
+ blitz_app_version = os.getenv("BLITZ_VERSION")
+ if blitz_app_name is None:
+ raise Exception("No blitz app name provided.")
+ enable_config_route = os.getenv("BLITZ_CONFIG_ROUTE", "").lower() == "true"
+ admin = os.getenv("BLITZ_ADMIN", "").lower() == "true"
+ blitz_app = blitz.get_app(blitz_app_name)
+ if blitz_app_version is not None:
+ blitz_app = blitz_app.get_version(Version.parse(blitz_app_version))
+
+ # TODO Maybe to remove
+ blitz_app.load()
+ blitz_api = BlitzAPI(
+ blitz_app,
+ enable_config_route,
+ *args,
+ docs_url=docs_url,
+ redoc_url=redoc_url,
+ **kwargs,
+ )
+
+ if not blitz_app_version:
+ run_migrations(
+ blitz_app=blitz_app,
+ in_memory=blitz_app._in_memory,
+ )
+
+ try:
+ generate_migration(
+ message="Blitz autogenerated migration",
+ blitz_app=blitz_app,
+ in_memory=blitz_app._in_memory,
+ )
+ except NoChangesDetectedError:
+ pass
+ else:
+ run_migrations(
+ blitz_app=blitz_app,
+ in_memory=blitz_app._in_memory,
+ )
+ else:
+ run_migrations(
+ blitz_app=blitz_app,
+ in_memory=blitz_app._in_memory,
+ is_release=True,
+ )
+
+ if admin:
+ # FIXME We need to fix the the relationship here.
+ # Proposed solution: "To silence this warning, add the parameter 'overlaps="todos"' to the 'Todo.todo_list' relationship."
+ # https://docs.sqlalchemy.org/en/20/errors.html#error-qzyx
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=SAWarning)
+ BlitzAdmin(blitz_app).mount_to(blitz_api)
+
+ print("\n[bold yellow]This is still an alpha. Please do not use in production.[/bold yellow]")
+ print("[bold yellow]Please report any issues on https://github.com/Paperz-org/blitz[/bold yellow]")
+ print(
+ "\n".join(
+ (
+ "\n[bold medium_purple1]Blitz app deployed.",
+ f" - Blitz UI : http://localhost:{get_settings().BLITZ_PORT}",
+ f" - Blitz admin : http://localhost:{get_settings().BLITZ_PORT}/admin",
+ f" - Swagger UI : http://localhost:{get_settings().BLITZ_PORT}/api/docs[/bold medium_purple1]\n",
+ )
+ )
+ )
+
+ init_ui(blitz_api=blitz_api)
+ return blitz_api
diff --git a/blitz/api/logs.py b/blitz/api/logs.py
new file mode 100644
index 0000000..3bd3d22
--- /dev/null
+++ b/blitz/api/logs.py
@@ -0,0 +1,79 @@
+from loguru import logger
+import logging
+import inspect
+import sys
+from typing import TYPE_CHECKING
+
+from blitz.app import BlitzApp
+
+if TYPE_CHECKING:
+ from blitz.api.blitz_api import BlitzAPI
+
+
+class InterceptHandler(logging.Handler):
+ def __init__(self, level: int = logging.NOTSET) -> None:
+ super().__init__(level)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ # Get corresponding Loguru level if it exists.
+ level: str | int
+ try:
+ level = logger.level(record.levelname).name
+ except ValueError:
+ level = record.levelno
+
+ if record.name in ("uvicorn.access",):
+ if record.args[2].startswith("/projects"): # type: ignore
+ record.name += ".ui"
+ elif record.args[2].startswith("/api"): # type: ignore
+ record.name += ".api"
+ elif record.args[2].startswith("/admin"): # type: ignore
+ record.name += ".admin"
+
+ # Find caller from where originated the logged message.
+ frame, depth = inspect.currentframe(), 0
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
+ frame = frame.f_back
+ depth += 1
+ if record.name in ["uvicorn.access.ui", "uvicorn.access.admin"]:
+ pass
+ else:
+ logger.opt(
+ depth=depth,
+ exception=record.exc_info,
+ ).log(level, record.getMessage())
+
+
+def filter_logs(record: logging.LogRecord, blitz_app: BlitzApp) -> bool:
+ if record["name"].startswith("uvicorn.access"): # type: ignore
+ return True
+ if record["name"].startswith("uvicorn.server"): # type: ignore
+ return True
+ if record["name"].startswith("uvicorn.lifespan"): # type: ignore
+ return True
+ if record["name"].startswith("uvicorn.error"): # type: ignore
+ return True
+ if record["name"].startswith("uvicorn.asgi"): # type: ignore
+ return True
+ if record["name"].startswith("uvicorn.protocols"): # type: ignore
+ return True
+ if record["extra"].get("blitz_app", "") == blitz_app.name: # type: ignore
+ return True
+ return False
+
+
+def configure(app: "BlitzAPI") -> None:
+ logger.remove()
+ logger.bind(app_name=app.blitz_app.name)
+ logger.add(
+ sys.stderr,
+ format=("{level: <9}" f" {app.blitz_app.name} " "{message}"),
+ colorize=True,
+ filter=lambda record: filter_logs(record, app.blitz_app), # type: ignore
+ )
+ logger.level("INFO", color="")
+ logger.level("DEBUG", color="")
+ logger.level("ERROR", color="")
+ logger.level("WARNING", color="")
+
+ logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
diff --git a/blitz/app.py b/blitz/app.py
new file mode 100644
index 0000000..e5628db
--- /dev/null
+++ b/blitz/app.py
@@ -0,0 +1,207 @@
+from pathlib import Path
+
+from blitz.db.errors import NoChangesDetectedError
+from blitz.models.base import BaseResourceModel, clear_metadata, create_resource_model
+from blitz.models.blitz.field import _BlitzNullValue, AllowedBlitzFieldTypes, BlitzField, BlitzType
+from blitz.models.blitz.file import BlitzFile
+from blitz.models.blitz.resource import BlitzResource, BlitzResourceConfig
+from blitz.parser import _find_blitz_file_path, parse_file
+from blitz.db.migrations import generate_migration, run_migrations
+import warnings
+from sqlalchemy import exc as sa_exc
+from semver import Version
+from loguru import logger
+
+
+class BlitzApp:
+ def __init__(
+ self, name: str, path: Path, file: BlitzFile, in_memory: bool = False, version: Version | None = None
+ ) -> None:
+ self.name = name
+ self.path = path
+ self.file = file
+ self.version = version
+ self.logger = logger.bind(blitz_app=self.name)
+
+ # TODO Change to a database connector to make BlitzApp agnostic of the database implementation
+ self._in_memory = in_memory
+ self.resources: list[BlitzResource] = []
+ self._is_loaded = False
+ self._base_resource_model: type[BaseResourceModel] = BaseResourceModel
+ self._available_version: list[Version] = []
+
+ self._load_versions()
+
+ def _load_versions(self) -> None:
+ if not self.path.exists() or not self.path.is_dir():
+ return
+
+ for directory in self.path.iterdir():
+ if directory.is_dir():
+ try:
+ version = Version.parse(directory.name)
+ except:
+ continue
+
+ try:
+ _find_blitz_file_path(self.path / str(version))
+ except:
+ raise ValueError(
+ f"Blitz app {self.name} has a version dir '{version}' without a blitz file inside."
+ )
+ self._available_version.append(version)
+
+ self._available_version = sorted(self._available_version)
+
+ def get_version(self, version: Version) -> "BlitzApp":
+ if version not in self._available_version:
+ raise ValueError(f"Version {version} not found for Blitz app {self.name}")
+ return BlitzApp(
+ name=self.name,
+ path=self.path,
+ file=parse_file(_find_blitz_file_path(self.path / str(version))),
+ in_memory=self._in_memory,
+ version=version,
+ )
+
+ def load(self) -> None:
+ """
+ Can be more elegant
+ """
+ if self._is_loaded:
+ return
+
+ models_by_name: dict[str, type[BaseResourceModel]] = {}
+ config_by_name: dict[str, BlitzResourceConfig] = {config.name: config for config in self.file.resources_configs}
+ for config in self.file.resources_configs:
+ relationships: dict[str, list[str]] = {}
+ for field in config.fields.values():
+ # Change the field type depending of the target table column type
+ if field.type == AllowedBlitzFieldTypes.foreign_key:
+ if not isinstance(field.foreign_key, _BlitzNullValue):
+ table, column = field.foreign_key.split(".")
+ if table not in config_by_name:
+ raise ValueError(f"Table `{table}` not found for foreign key `{field.foreign_key}`")
+
+ if column in self._base_resource_model.__default_columns__:
+ field.type = BlitzType(
+ type=AllowedBlitzFieldTypes.from_class(
+ self._base_resource_model.__annotations__[column]
+ )
+ )
+ elif column in config_by_name[table].fields:
+ field.type = config_by_name[table].fields[column].type
+ else:
+ raise ValueError(
+ f"Column `{column}` not found in table `{table}` for foreign key `{field.foreign_key}`"
+ )
+ else:
+ raise ValueError(f"Foreign key `{field.foreign_key}` is missing.")
+
+ # We loop through the relationships to create the relationship field in the other table
+ for relationship in relationships:
+ config.fields[relationship.lower()] = BlitzField(
+ type=AllowedBlitzFieldTypes.relationship, # type: ignore
+ relationship=relationship,
+ )
+ try:
+ model = create_resource_model(config, already_created_models=models_by_name)
+ models_by_name[config.name] = model
+ except Exception:
+ raise
+ self.resources.append(BlitzResource(config=config, model=model))
+
+ self._is_loaded = True
+
+ def release(self, level: str, force: bool = False) -> Version:
+ clear_metadata()
+
+ latest_version = self._available_version[-1] if self._available_version else None
+
+ # If there is already a released version
+ if latest_version is not None:
+ # Check if the path (then the app) exists
+ latest_version_path = self.path / f"{latest_version}"
+ assert latest_version_path.exists()
+
+ # We bump the version regarding the release level
+ match level:
+ case "major":
+ new_version = latest_version.bump_major()
+ case "minor":
+ new_version = latest_version.bump_minor()
+ case "patch":
+ new_version = latest_version.bump_patch()
+
+ # We run the migrations to the latest version
+ latest_blitz_app = BlitzApp(
+ "", latest_version_path, parse_file(latest_version_path / self.file.path.name), in_memory=True
+ )
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=sa_exc.SAWarning)
+ latest_blitz_app.load()
+
+ run_migrations(
+ blitz_app=self,
+ in_memory=True,
+ is_release=True,
+ )
+ # Else, we consider it's the first release
+ else:
+ try:
+ # Use the version in the blitz file as initial version
+ new_version = Version.parse(self.file.config.version)
+ except:
+ logger.warning(
+ (
+ f"Version in blitz file {self.file.path} is not a valid semver version."
+ "Using 0.1.0 as the new version."
+ )
+ )
+ new_version = Version(major=0, minor=1, patch=0)
+
+ new_version_path = self.path / str(new_version)
+ new_blitz_file = self.file.model_copy()
+ new_blitz_file.config.version = str(new_version)
+
+ # Now we run generate the migration for this blitz app
+ released_blitz_app = BlitzApp(
+ name=self.name,
+ path=self.path,
+ file=self.file,
+ in_memory=True,
+ )
+ clear_metadata()
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=sa_exc.SAWarning)
+ released_blitz_app.load()
+
+ try:
+ generate_migration(
+ message="Blitz autogenerated migration",
+ blitz_app=released_blitz_app,
+ in_memory=True,
+ is_release=True,
+ )
+ except NoChangesDetectedError:
+ if force is False:
+ raise
+
+ # If everything went well, we create the new version directory
+ if not new_version_path.exists():
+ new_version_path.mkdir(parents=True)
+
+ # We copy the current blitz file to the new version directory
+ new_version_blitz_file_path = new_version_path / self.file.path.name
+ new_version_blitz_file_path.write_text(self.file.path.read_text())
+
+ # Automatically update the blitz file version needs a specific write method
+ # new_blitz_file = self.file.model_copy()
+ # new_blitz_file.config.version = str(new_version)
+ # new_version_blitz_file_path = new_version_path / self.file.path.name
+ # with open(new_version_blitz_file_path, "w") as f:
+ # new_blitz_config = new_blitz_file.config.version = str(new_version)
+
+ return new_version
diff --git a/blitz/cli/__init__.py b/blitz/cli/__init__.py
new file mode 100644
index 0000000..13ec6cd
--- /dev/null
+++ b/blitz/cli/__init__.py
@@ -0,0 +1,5 @@
+from .app import app
+
+__all__ = [
+ "app",
+]
diff --git a/blitz/cli/app.py b/blitz/cli/app.py
new file mode 100644
index 0000000..88e66fd
--- /dev/null
+++ b/blitz/cli/app.py
@@ -0,0 +1,19 @@
+# from .commands.swagger import list_routes
+from blitz.cli.commands.swagger import list_routes
+from .commands.start import start_blitz
+from .commands.clean import clean_blitz
+from .commands.list import list_blitz_app
+from .commands.create import create_blitz_app
+from .commands.release import release_blitz
+
+import typer
+
+
+app = typer.Typer()
+app.command(name="create")(create_blitz_app)
+app.command(name="list")(list_blitz_app)
+app.command(name="start")(start_blitz)
+app.command(name="release")(release_blitz)
+app.command(name="swagger")(list_routes)
+# dev only
+# app.command(name="clean")(clean_blitz)
diff --git a/blitz/cli/commands/__init__.py b/blitz/cli/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/cli/commands/clean.py b/blitz/cli/commands/clean.py
new file mode 100644
index 0000000..9bcbe3b
--- /dev/null
+++ b/blitz/cli/commands/clean.py
@@ -0,0 +1,16 @@
+import os
+import glob
+
+
+def clean_blitz() -> None:
+ try:
+ os.remove("app.db")
+ except FileNotFoundError:
+ pass
+
+ for file in glob.glob("**/versions/*_migratiob.py"):
+ if os.path.isfile(file):
+ try:
+ os.remove(file)
+ except FileNotFoundError:
+ pass
diff --git a/blitz/cli/commands/compile.py b/blitz/cli/commands/compile.py
new file mode 100644
index 0000000..98e8b36
--- /dev/null
+++ b/blitz/cli/commands/compile.py
@@ -0,0 +1,7 @@
+import typer
+
+from typing import Annotated
+
+
+def compile_blitz(filename: Annotated[str, typer.Argument()]) -> None:
+ print(f"{filename}")
diff --git a/blitz/cli/commands/create.py b/blitz/cli/commands/create.py
new file mode 100644
index 0000000..f6617ac
--- /dev/null
+++ b/blitz/cli/commands/create.py
@@ -0,0 +1,77 @@
+from pathlib import Path
+from typing import Annotated, Optional
+from rich import prompt, print
+from blitz.models.blitz.file import BlitzFile
+import yaml
+from blitz.models.blitz.config import BlitzAppConfig
+import typer
+
+DEFAULT_VERSION = "0.1.0"
+
+
+def write_blitz_file(blitz_file: BlitzFile, blitz_file_format: str) -> Path:
+ if blitz_file_format == "json":
+ blitz_file_data = blitz_file.model_dump_json(indent=4, by_alias=True)
+ elif blitz_file_format == "yaml":
+ blitz_file_data = yaml.dump(blitz_file.model_dump(by_alias=True), default_flow_style=False)
+ else:
+ raise ValueError("Invalid blitz file format")
+
+ with open(blitz_file.path, "w") as file:
+ file.write(blitz_file_data)
+
+ return blitz_file.path
+
+
+def create_blitz_app(
+ blitz_app_name: Annotated[
+ Optional[str], typer.Argument(help="The name of the blitz app you want to create")
+ ] = None,
+) -> None:
+ if not blitz_app_name:
+ # Interactive prompt to create a new blitz app
+ blitz_app_name = prompt.Prompt.ask(
+ "Enter the name of your blitz app",
+ default="Random Blitz App",
+ )
+ blitz_app_description = prompt.Prompt.ask(
+ "Enter the description of your blitz app",
+ default="",
+ )
+ blitz_file_format = prompt.Prompt.ask(
+ "Choose the format of the blitz file (can be changed later)",
+ choices=["json", "yaml"],
+ default="yaml",
+ )
+
+ blitz_app_path = Path(blitz_app_name.lower().replace(" ", "-"))
+ try:
+ # Create the blitz file
+ blitz_file = BlitzFile(
+ path=blitz_app_path / f"blitz.{blitz_file_format}",
+ config=BlitzAppConfig(
+ name=blitz_app_name,
+ description=blitz_app_description,
+ version=DEFAULT_VERSION,
+ ),
+ resources_configs=[],
+ raw_file={},
+ )
+ except Exception as e:
+ print(f"[red bold]Error[/red bold] while creating the blitz file: {e}")
+ typer.Exit(code=1)
+ try:
+ # Create the blitz app directory, the .blitz file and the blitz file
+ blitz_app_path.mkdir(parents=True)
+ blitz_app_file_path = blitz_app_path / ".blitz"
+ blitz_app_file_path.touch()
+ blitz_file_path = write_blitz_file(blitz_file, blitz_file_format)
+ with open(blitz_app_file_path, "w") as blitz_app_file:
+ blitz_app_file.write(str(blitz_file_path))
+ except Exception as e:
+ print(f"[red bold]Error[/red bold] while creating the blitz app in the file system: {e}")
+ typer.Exit(code=1)
+
+ print(f"\n[medium_purple1 bold]{blitz_app_name}[/medium_purple1 bold] created successfully !")
+ print("To start your app, you can use:")
+ print(f" [bold medium_purple1]blitz start {blitz_app_path}[/bold medium_purple1]")
diff --git a/blitz/cli/commands/list.py b/blitz/cli/commands/list.py
new file mode 100644
index 0000000..dd3a537
--- /dev/null
+++ b/blitz/cli/commands/list.py
@@ -0,0 +1,22 @@
+from rich.console import Console
+from rich.table import Table
+from blitz.core import BlitzCore
+
+
+from blitz.app import BlitzApp
+
+
+def print_blitz_app(blitz_apps: list[BlitzApp]) -> None:
+ console = Console()
+ table = Table("Blitz app name", "Version")
+ if len(blitz_apps) > 0:
+ for blitz_app in blitz_apps:
+ table.add_row(blitz_app.name, blitz_app.file.config.version)
+ else:
+ table.add_row("No blitz app found.")
+ console.print(table)
+
+
+def list_blitz_app() -> None:
+ blitz = BlitzCore()
+ print_blitz_app(blitz.apps)
diff --git a/blitz/cli/commands/release.py b/blitz/cli/commands/release.py
new file mode 100644
index 0000000..7edd7a4
--- /dev/null
+++ b/blitz/cli/commands/release.py
@@ -0,0 +1,31 @@
+from typing import Annotated
+from blitz import BlitzCore
+import typer
+from rich import print
+import enum
+
+from blitz.db.errors import NoChangesDetectedError
+
+
+class ReleaseLevel(enum.Enum):
+ major = "major"
+ minor = "minor"
+ patch = "patch"
+
+
+def release_blitz(
+ blitz_app_name: Annotated[str, typer.Argument(..., help="Blitz app to release")],
+ level: Annotated[ReleaseLevel, typer.Argument(..., help="Release level")],
+ force: Annotated[bool, typer.Option(help="Force the release even if no changes are detected")] = False,
+) -> None:
+ blitz = BlitzCore()
+
+ app = blitz.get_app(blitz_app_name)
+ try:
+ new_version = app.release(level.value, force=force)
+ except NoChangesDetectedError:
+ typer.echo(f"No changes detected since the latest version. Use --force to release anyway.")
+ raise typer.Exit(code=1)
+ typer.echo(f"Blitz app {blitz_app_name} released at version {new_version}")
+ typer.echo("You can now start your versioned blitz app by running:")
+ print(f" [bold medium_purple1]blitz start {blitz_app_name} --version {new_version}[/bold medium_purple1]")
diff --git a/blitz/cli/commands/start.py b/blitz/cli/commands/start.py
new file mode 100644
index 0000000..f429fda
--- /dev/null
+++ b/blitz/cli/commands/start.py
@@ -0,0 +1,73 @@
+import time
+from semver import Version
+import typer
+import os
+import uvicorn
+
+from pathlib import Path
+from typing import Annotated, Optional
+from uvicorn.supervisors import ChangeReload
+
+from blitz.api import create_blitz_api
+from blitz.core import BlitzCore
+
+from blitz.settings import get_settings
+from rich import print
+
+
+def start_blitz(
+ blitz_app_name: Annotated[str, typer.Argument(..., help="Blitz app name")],
+ admin: Annotated[bool, typer.Option(help="Don't create admin.")] = True,
+ port: Annotated[int, typer.Option(help="Define the port of the server")] = get_settings().BLITZ_PORT,
+ config_route: Annotated[bool, typer.Option(help="Enable the blitz config route.")] = True,
+ hot_reload: Annotated[bool, typer.Option(help="Enable the hot reload.")] = True,
+ version: Annotated[Optional[str], typer.Option(help="Define the version of the app.")] = None,
+) -> None:
+ blitz = BlitzCore()
+
+ try:
+ blitz_app = blitz.get_app(blitz_app_name)
+ if version is not None:
+ blitz_app = blitz_app.get_version(Version.parse(version))
+ except Exception as exc:
+ print(f"[red bold]There is no blitz app named {blitz_app_name}[/red bold]")
+ print("To list the available blitz apps run:")
+ print("[bold] blitz list[bold]")
+ print(f"Error: {exc}")
+ raise typer.Exit()
+
+ print(
+ """[bold medium_purple1]
+██████╗ ██╗ ██╗████████╗███████╗ ██████╗ ██╗ ██████╗
+██╔══██╗██║ ██║╚══██╔══╝╚══███╔╝ ██╔═████╗ ███║ ██╔═████╗
+██████╔╝██║ ██║ ██║ ███╔╝ ██║██╔██║ ╚██║ ██║██╔██║
+██╔══██╗██║ ██║ ██║ ███╔╝ ████╔╝██║ ██║ ████╔╝██║
+██████╔╝███████╗██║ ██║ ███████╗ ╚██████╔╝██╗██║██╗╚██████╔╝
+╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝╚═╝╚═╝ ╚═════╝
+ [/bold medium_purple1]"""
+ )
+ time.sleep(1)
+ if hot_reload:
+ # Need to be refacto
+ os.environ["BLITZ_APP"] = str(blitz_app.name)
+ if version is not None:
+ os.environ["BLITZ_VERSION"] = str(version)
+ os.environ["BLITZ_ADMIN"] = str(admin).lower()
+ os.environ["BLITZ_CONFIG_ROUTE"] = str(config_route).lower()
+
+ server_config = uvicorn.Config(
+ "blitz.api:create_blitz_api",
+ factory=True,
+ host="localhost",
+ port=port,
+ reload=True,
+ reload_includes=str(blitz_app.file.path.relative_to(Path.cwd())),
+ reload_excludes=["*_migration.py", "migrations/*.py"],
+ log_config=None,
+ log_level="info",
+ )
+ server = uvicorn.Server(server_config)
+ ChangeReload(server_config, target=server.run, sockets=[server_config.bind_socket()]).run()
+ else:
+ blitz_api = create_blitz_api(blitz_app, enable_config_route=config_route, admin=admin)
+ uvicorn.run(blitz_api, host="localhost", port=port, log_config=None, log_level="warning")
diff --git a/blitz/cli/commands/swagger.py b/blitz/cli/commands/swagger.py
new file mode 100644
index 0000000..036db09
--- /dev/null
+++ b/blitz/cli/commands/swagger.py
@@ -0,0 +1,100 @@
+import typer
+from semver import Version
+from typing import Annotated, Optional
+from blitz.core import BlitzCore
+from rich import print
+from blitz.models import BlitzResource
+from rich.style import Style
+from rich.panel import Panel
+
+
+class SwaggerPrinter:
+ POST_STYLE = Style(color="#49cc90")
+ GET_STYLE = Style(color="#61affe")
+ DELETE_STYLE = Style(color="#f93e3e")
+ PATCH_STYLE = Style(color="#fca130")
+ BOLD_STYLE = "[white bold]"
+
+ def __init__(self, routes: list[BlitzResource]) -> None:
+ self.routes = routes
+
+ def _get_create_panel(self, resource_name: str):
+ return Panel(
+ f"[{self.POST_STYLE}]POST [white bold]/{resource_name} [white dim frame]Create One",
+ border_style=self.POST_STYLE,
+ )
+
+ def _get_read_panel(self, resource_name: str):
+
+ return [
+ Panel(
+ f"[{self.GET_STYLE}]GET [white bold]/{resource_name} [white dim frame]Get One",
+ border_style=self.GET_STYLE,
+ ),
+ Panel(
+ f"[{self.GET_STYLE}]GET [white bold]/{resource_name}/{{item_id}} [white dim frame]Get All",
+ border_style=self.GET_STYLE,
+ ),
+ ]
+
+ def _get_can_delete_panel(self, resource_name: str):
+ return Panel(
+ f"[{self.DELETE_STYLE}]DELETE [white bold]/{resource_name}/{{item_id}} [white dim frame]Delete One",
+ border_style=self.DELETE_STYLE,
+ )
+
+ def _get_can_update_panel(self, resource_name: str):
+ return Panel(
+ f"[{self.PATCH_STYLE}]PUT [white bold]/{resource_name}/{{item_id}} [white dim frame]Update One",
+ border_style=self.PATCH_STYLE,
+ )
+
+ def _get_name_panel(self, resource_name: str):
+ return Panel(f"[white bold]{resource_name.upper()}")
+
+ def get_panels(self):
+ panels = []
+ for resource in self.routes:
+ resource_name = resource.config.name.lower()
+ panels.append(self._get_name_panel(resource_name))
+ if resource.config.can_create:
+ panels.append(self._get_create_panel(resource_name))
+ if resource.config.can_read:
+ panels.extend(self._get_read_panel(resource_name))
+ if resource.config.can_delete:
+ panels.append(self._get_can_delete_panel(resource_name))
+ panels.append("\n")
+ return panels
+
+ def print(self):
+ panels = self.get_panels()
+ for panel in panels:
+ print(panel)
+
+
+def list_routes(
+ blitz_app_name: Annotated[str, typer.Argument(..., help="Blitz app name")],
+ model: Annotated[str, typer.Option()] = None,
+ version: Annotated[
+ Optional[str], typer.Option(help="Define the version of the app.")
+ ] = None,
+) -> None:
+ blitz = BlitzCore()
+ try:
+ blitz_app = blitz.get_app(blitz_app_name)
+ if version is not None:
+ blitz_app = blitz_app.get_version(Version.parse(version))
+ blitz_app.load()
+ except Exception as exc:
+ print(f"[red bold]There is no blitz app named {blitz_app_name}[/red bold]")
+ print("To list the available blitz apps run:")
+ print("[bold] blitz list[bold]")
+ print(f"Error: {exc}")
+ raise typer.Exit()
+ if model:
+ for resource in blitz_app.resources:
+ if resource.config.name.lower() == model.lower():
+ resources = [resource]
+ else:
+ resources = blitz_app.resources
+ SwaggerPrinter(resources).print()
diff --git a/blitz/core.py b/blitz/core.py
new file mode 100644
index 0000000..cfc6192
--- /dev/null
+++ b/blitz/core.py
@@ -0,0 +1,39 @@
+from pathlib import Path
+
+from blitz.app import BlitzApp
+from blitz.parser import _find_blitz_app_path, _find_blitz_file_path, parse_file
+from blitz.settings import DBTypes, get_settings
+
+
+class BlitzCore:
+ BLITZ_DOT_FILE = ".blitz"
+
+ def __init__(self) -> None:
+ self.apps: list[BlitzApp] = []
+ self._discover_apps()
+
+ def get_app(self, name: str) -> BlitzApp:
+ for app in self.apps:
+ if app.name == name:
+ return app
+ raise Exception(f"Blitz app {name} not found.")
+
+ def _discover_apps(self) -> None:
+ """
+ Discovers Blitz apps in the current directory and its subdirectories.
+ """
+
+ for dotfile in Path(".").glob(f"**/*{self.BLITZ_DOT_FILE}"):
+ blitz_app_name = dotfile.parent.name
+ blitz_app_path = _find_blitz_app_path(blitz_app_name)
+ blitz_file_path = _find_blitz_file_path(blitz_app_path)
+ blitz_file = parse_file(blitz_file_path)
+
+ self.apps.append(
+ BlitzApp(
+ blitz_app_name,
+ blitz_app_path,
+ blitz_file,
+ in_memory=get_settings().BLITZ_DB_TYPE == DBTypes.MEMORY,
+ )
+ )
diff --git a/blitz/db/__init__.py b/blitz/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/db/db.py b/blitz/db/db.py
new file mode 100644
index 0000000..fcd40ae
--- /dev/null
+++ b/blitz/db/db.py
@@ -0,0 +1,54 @@
+from pathlib import Path
+
+from sqlmodel import Session, create_engine
+from typing import Any, Generator
+from functools import lru_cache
+from sqlalchemy import Engine, event
+from sqlalchemy import pool
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from blitz.app import BlitzApp
+
+
+def _fk_pragma_on_connect(dbapi_con: Any, con_record: Any) -> None:
+ """
+ Enable foreign keys on SQLite.
+ """
+ dbapi_con.execute("pragma foreign_keys=ON")
+
+
+@lru_cache
+def get_engine(url: str) -> Engine:
+ return create_engine(url, connect_args={"check_same_thread": False}, poolclass=pool.StaticPool)
+
+
+# FIXME find how to avoid that
+@lru_cache
+def get_sqlite_engine(
+ blitz_app: "BlitzApp",
+ in_memory: bool,
+ file_name: str,
+) -> Engine:
+ # TODO find a better implementation
+ if blitz_app.version:
+ file_name = f"{blitz_app.version}/{file_name}"
+
+ if in_memory is True:
+ url = "sqlite://"
+ else:
+ url = f"sqlite:///{blitz_app.path.absolute()}/{file_name}"
+ engine = get_engine(url)
+ event.listen(engine, "connect", _fk_pragma_on_connect)
+ return engine
+
+
+def get_db(blitz_app: "BlitzApp", in_memory: bool, file_name: str) -> Generator[Session, None, None]:
+ with Session(get_sqlite_engine(blitz_app, in_memory, file_name)) as session:
+ try:
+ yield session
+ session.commit()
+ except:
+ session.rollback()
+ finally:
+ session.close()
diff --git a/blitz/db/errors.py b/blitz/db/errors.py
new file mode 100644
index 0000000..a23625b
--- /dev/null
+++ b/blitz/db/errors.py
@@ -0,0 +1,2 @@
+class NoChangesDetectedError(Exception):
+ pass
diff --git a/blitz/db/migrations.py b/blitz/db/migrations.py
new file mode 100644
index 0000000..28cde05
--- /dev/null
+++ b/blitz/db/migrations.py
@@ -0,0 +1,128 @@
+from pathlib import Path
+
+from alembic.script import Script
+from argparse import Namespace
+from alembic.config import Config
+from alembic.util import AutogenerateDiffsDetected
+from functools import lru_cache
+
+# from blitz.db.db import get_sqlite_engine
+
+from alembic.command import check, revision, upgrade
+
+from blitz.db.db import get_sqlite_engine
+from blitz.db.errors import NoChangesDetectedError
+import logging
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from blitz.app import BlitzApp
+
+
+logging.getLogger("alembic").setLevel(logging.CRITICAL)
+
+DEFAULT_MIGRATION_FILE_NAME_TEMPLATE = "%%(epoch)s_%%(rev)s"
+RELEASE_MIGRATION_FILE_NAME_TEMPLATE = "migration"
+
+
+def get_alembic_config(
+ blitz_app: "BlitzApp",
+ file_name: str,
+ in_memory: bool = False,
+ is_release: bool = False,
+) -> Config:
+
+ # TODO find a better implementation
+ if blitz_app.version:
+ file_name = f"{blitz_app.version}/{file_name}"
+
+ if in_memory:
+ url = "sqlite://"
+ else:
+ url = f"sqlite:///{blitz_app.path.absolute()}/{file_name}"
+
+ if is_release:
+ file_name_template = RELEASE_MIGRATION_FILE_NAME_TEMPLATE
+ migrations_paths = set(
+ [str(blitz_app.path / str(version)) for version in blitz_app._available_version]
+ + [str(blitz_app.path / str(blitz_app.file.config.version))]
+ )
+ else:
+ file_name_template = DEFAULT_MIGRATION_FILE_NAME_TEMPLATE
+ migrations_paths = set([str(blitz_app.path / "migrations")])
+
+ alembic_cfg = Config(cmd_opts=Namespace(quiet=True))
+
+ # FIXME: Ugly way to find the alembic folder
+ alembic_cfg.set_main_option("script_location", f"{Path(__file__).parent.parent.resolve()}/alembic")
+ alembic_cfg.set_main_option("sqlalchemy.url", url)
+ alembic_cfg.set_main_option("file_template", file_name_template)
+ alembic_cfg.set_main_option("version_locations", " ".join(migrations_paths))
+
+ alembic_cfg.set_main_option("recursive_version_locations", "true")
+
+ return alembic_cfg
+
+
+def generate_migration(
+ message: str,
+ blitz_app: "BlitzApp",
+ file_name: str = "app.db",
+ in_memory: bool = False,
+ is_release: bool = False,
+) -> Path:
+ alembic_config = get_alembic_config(
+ blitz_app=blitz_app,
+ file_name=file_name,
+ in_memory=in_memory,
+ is_release=is_release,
+ )
+
+ # We attach the connection to the alembic config following the alembic sqlalchemy cookbook
+ # https://alembic.sqlalchemy.org/en/latest/cookbook.html#sharing-a-connection-across-one-or-more-programmatic-migration-commands
+ with get_sqlite_engine(blitz_app=blitz_app, in_memory=in_memory, file_name=file_name).begin() as connection:
+ alembic_config.attributes["connection"] = connection
+
+ try:
+ check(alembic_config)
+ except AutogenerateDiffsDetected:
+ if is_release:
+ version_path = blitz_app.path / blitz_app.file.config.version
+ else:
+ version_path = blitz_app.path / "migrations"
+
+ # Generate the migration
+ migration = revision(
+ alembic_config,
+ message=message,
+ autogenerate=True,
+ version_path=str(version_path),
+ )
+ if isinstance(migration, Script):
+ return Path(migration.path)
+ else:
+ raise Exception("Too many migration generated.")
+ else:
+ raise NoChangesDetectedError("No migration to generate.")
+
+
+def run_migrations(
+ blitz_app: "BlitzApp",
+ file_name: str = "app.db",
+ in_memory: bool = False,
+ is_release: bool = False,
+) -> None:
+ # We attach the connection to the alembic config following the alembic sqlalchemy cookbook
+ # https://alembic.sqlalchemy.org/en/latest/cookbook.html#sharing-a-connection-across-one-or-more-programmatic-migration-commands
+ with get_sqlite_engine(blitz_app=blitz_app, in_memory=in_memory, file_name=file_name).begin() as connection:
+ alembic_config = get_alembic_config(
+ blitz_app=blitz_app,
+ file_name=file_name,
+ in_memory=in_memory,
+ is_release=is_release,
+ )
+ alembic_config.attributes["connection"] = connection
+ upgrade(
+ alembic_config,
+ revision="head",
+ )
diff --git a/blitz/models/__init__.py b/blitz/models/__init__.py
new file mode 100644
index 0000000..9168327
--- /dev/null
+++ b/blitz/models/__init__.py
@@ -0,0 +1,9 @@
+from .blitz import BlitzResource, BlitzResourceConfig, BlitzField
+from .base import BaseResourceModel
+
+__all__ = [
+ "BlitzResource",
+ "BlitzResourceConfig",
+ "BlitzField",
+ "BaseResourceModel",
+]
diff --git a/blitz/models/base.py b/blitz/models/base.py
new file mode 100644
index 0000000..4a32049
--- /dev/null
+++ b/blitz/models/base.py
@@ -0,0 +1,224 @@
+from typing import Optional, TypeVar
+import typing
+import uuid
+from sqlalchemy.orm import declared_attr
+from pydantic import BaseModel, create_model
+from uuid import UUID
+from sqlmodel import Relationship, SQLModel, Field
+from pydantic.fields import FieldInfo
+from types import UnionType
+from blitz.models.blitz.field import _BlitzNullValue, AllowedBlitzFieldTypes
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from blitz.models.blitz.resource import BlitzResourceConfig
+
+T = TypeVar("T", bound="BaseModel")
+
+
+class BaseResourceModel(SQLModel):
+ """
+ Base class for SQL models.
+
+ Attributes:
+ id (UUID): The primary key of the model.
+ """
+
+ @declared_attr # type: ignore
+ def __tablename__(cls) -> str:
+ """
+ Get the table name for the model.
+
+ Returns:
+ str: The table name.
+ """
+ return cls.__name__
+
+ id: UUID = Field(
+ default_factory=uuid.uuid4,
+ description="Unique identifier for this resource.",
+ primary_key=True,
+ index=True,
+ nullable=False,
+ )
+
+ __default_columns__ = ["id"]
+
+ @classmethod
+ def read_model(cls: type["BaseResourceModel"], *, depth: int = 1) -> type[BaseModel]:
+ """
+ Create a read model based on the current model.
+
+ Args:
+ depth: The depth of relationships to include in the read model.
+
+ Returns:
+ The read model class.
+ """
+ excluded_fields = [] # type: ignore
+ new_annotations = {}
+ fields = dict(cls.model_fields.items())
+
+ # If depth is 0, we don't want to include any relationship representation
+ if depth != 0:
+ fields.update(
+ {
+ relationship: cls.__annotations__[relationship]
+ for relationship in cls.__sqlmodel_relationships__.keys()
+ }
+ )
+
+ for field_name, field_info in fields.items():
+ # if field_name in excluded_fields and not isinstance(field_info, Mapped):
+ if field_name not in excluded_fields:
+ if type(field_info) == typing._GenericAlias: # type: ignore
+ if type(field_info.__args__[0]) == BaseResourceModel:
+ type_ = field_info.__args__[0].read_model(depth=depth - 1)
+ else:
+ type_ = field_info.__args__[0]
+ elif type(field_info) == UnionType: # type: ignore
+ type_ = field_info.__args__ # type: ignore
+ elif isinstance(field_info, FieldInfo):
+ type_ = field_info.annotation # type: ignore
+
+ new_annotations[field_name] = type_
+
+ new_model = type(f"{cls.__name__}Read", (BaseModel,), {"__annotations__": new_annotations})
+ return new_model
+
+ @classmethod
+ def create_model(cls: type["BaseResourceModel"]) -> type[BaseModel]:
+ """
+ Create a create model based on the current model.
+
+ Returns:
+ The create model class.
+ """
+ excluded_fields = cls.__default_columns__
+ new_annotations = {}
+
+ for field_name, field_info in cls.model_fields.items():
+ # if field_name in excluded_fields and not isinstance(field_info, Mapped):
+ if field_name not in excluded_fields:
+ if type(field_info) == typing._GenericAlias: # type: ignore
+ type_ = field_info.__args__[0]
+ continue
+ elif isinstance(field_info, FieldInfo):
+ type_ = field_info.annotation
+ else:
+ type_ = field_info
+
+ new_annotations[field_name] = type_
+
+ new_model = type(f"{cls.__name__}Create", (BaseModel,), {"__annotations__": new_annotations})
+ return new_model
+
+ @classmethod
+ def update_model(cls: type["BaseResourceModel"]) -> type[BaseModel]:
+ """
+ Create an update model based on the current model.
+
+ Returns:
+ The update model class.
+ """
+ excluded_fields = cls.__default_columns__
+ new_annotations = {}
+
+ for field_name, field_info in cls.model_fields.items():
+ # if field_name in excluded_fields and not isinstance(field_info, Mapped):
+ if field_name not in excluded_fields:
+ if type(field_info) == typing._GenericAlias: # type: ignore
+ type_ = field_info.__args__[0]
+ continue
+ elif isinstance(field_info, FieldInfo):
+ type_ = field_info.annotation
+ else:
+ type_ = field_info
+
+ new_annotations[field_name] = type_
+
+ new_model = type(f"{cls.__name__}Update", (BaseModel,), {"__annotations__": new_annotations})
+ return new_model
+
+
+def create_resource_model(
+ resource_config: "BlitzResourceConfig", already_created_models: dict[str, type[BaseResourceModel]]
+) -> type[BaseResourceModel]:
+ """
+ Creates a new BaseSqlModel class based on the provided BlitzResourceConfig.
+
+ Args:
+ resource_config: The configuration object containing the information for creating the model.
+ already_created_models: A dictionary containing the already created models with their corresponding table names as key.
+
+ Returns:
+ The newly created BaseSqlModel class based on the provided BlitzResourceConfig.
+
+ This function iterates over the fields defined in the resource_config and creates the corresponding fields for the model.
+ It handles different field types such as foreign keys and relationships, and sets the appropriate attributes for each field.
+ """
+
+ fields: dict[Any, Any] = {}
+ for field_name, field in resource_config.fields.items():
+ extra = {}
+ if not isinstance(field.default, _BlitzNullValue):
+ extra["default"] = field.default
+
+ if not isinstance(field.foreign_key, _BlitzNullValue):
+ extra["foreign_key"] = field.foreign_key
+
+ if not isinstance(field.nullable, _BlitzNullValue):
+ extra["nullable"] = field.nullable
+ else:
+ extra["nullable"] = True
+
+ if extra["nullable"] is True and not "default" in extra:
+ extra["default"] = None
+
+ if not isinstance(field.unique, _BlitzNullValue):
+ extra["unique"] = field.unique
+
+ if field.type == AllowedBlitzFieldTypes.foreign_key:
+ pass
+ elif field.type == AllowedBlitzFieldTypes.relationship:
+ field_info = Relationship()
+ if not isinstance(field.relationship, _BlitzNullValue):
+ if field.relationship not in already_created_models:
+ try:
+ field_type = eval(field.relationship)
+ except NameError:
+ field_type = f"{field.relationship}"
+ if field.relationship_list is True:
+ field_type = list[field_type] # type: ignore
+ else:
+ field_type = already_created_models[field.relationship]
+ else:
+ raise ValueError(f"Relationship `{field.relationship}` is missing.")
+ else:
+ field_info = Field(**extra)
+ field_type = field.type.value
+
+ if extra.get("nullable", False) is True:
+ field_type = Optional[field_type]
+
+ fields[field_name] = (
+ field_type,
+ field_info,
+ )
+
+ new_class = create_model(
+ resource_config.name,
+ __base__=BaseResourceModel,
+ __cls_kwargs__={"table": True},
+ **fields,
+ )
+
+ return new_class
+
+
+def clear_metadata() -> None:
+ """
+ Clear the metadata of the BaseResourceModel and SQLModel classes.
+ """
+ SQLModel.metadata.clear()
+ BaseResourceModel.metadata.clear()
diff --git a/blitz/models/blitz/__init__.py b/blitz/models/blitz/__init__.py
new file mode 100644
index 0000000..99ca7f3
--- /dev/null
+++ b/blitz/models/blitz/__init__.py
@@ -0,0 +1,15 @@
+from .config import BlitzAppConfig
+from .resource import BlitzResourceConfig, BlitzResource
+from .field import BlitzField, AllowedBlitzFieldTypes, BlitzType, _BlitzNullValue
+from .file import BlitzFile
+
+__all__ = [
+ "BlitzAppConfig",
+ "BlitzResourceConfig",
+ "BlitzResource",
+ "BlitzField",
+ "AllowedBlitzFieldTypes",
+ "BlitzType",
+ "_BlitzNullValue",
+ "BlitzFile",
+]
diff --git a/blitz/models/blitz/config.py b/blitz/models/blitz/config.py
new file mode 100644
index 0000000..472f310
--- /dev/null
+++ b/blitz/models/blitz/config.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+
+class BlitzAppConfig(BaseModel):
+ """
+ The Blitz config is the configuration for a Blitz app. It contains the name, description, and version of the app.
+ """
+
+ name: str
+ description: str | None = None
+ version: str
diff --git a/blitz/models/blitz/field.py b/blitz/models/blitz/field.py
new file mode 100644
index 0000000..850059c
--- /dev/null
+++ b/blitz/models/blitz/field.py
@@ -0,0 +1,171 @@
+import enum
+from blitz.models.utils import ContainsEnum
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, Field, computed_field, field_validator, model_serializer
+import uuid
+from datetime import datetime
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class _BlitzNullValue:
+ """
+ This class is used to represent a null value in BlitzField.
+ """
+
+ ...
+
+
+class AllowedBlitzFieldTypes(enum.StrEnum, metaclass=ContainsEnum):
+ str = "str"
+ int = "int"
+ float = "float"
+ uuid = "uuid"
+ datetime = "datetime"
+ foreign_key = "foreign_key"
+ relationship = "relationship"
+
+ @classmethod
+ def from_class(cls, v: Any) -> "AllowedBlitzFieldTypes":
+ return cls(v.__name__.lower())
+
+
+class BlitzType(BaseModel):
+ TYPE_MAPPING: ClassVar[dict[AllowedBlitzFieldTypes, Any]] = {
+ AllowedBlitzFieldTypes.str: str,
+ AllowedBlitzFieldTypes.int: int,
+ AllowedBlitzFieldTypes.float: float,
+ AllowedBlitzFieldTypes.uuid: uuid.UUID,
+ AllowedBlitzFieldTypes.datetime: datetime,
+ }
+ TYPE_FACTORY_MAPPING: ClassVar[dict[AllowedBlitzFieldTypes, Any]] = {
+ AllowedBlitzFieldTypes.str: lambda: "string",
+ AllowedBlitzFieldTypes.int: int,
+ AllowedBlitzFieldTypes.float: float,
+ AllowedBlitzFieldTypes.uuid: uuid.uuid4,
+ AllowedBlitzFieldTypes.datetime: datetime.now,
+ }
+ type: AllowedBlitzFieldTypes
+
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ for allowed_type in AllowedBlitzFieldTypes:
+ if allowed_type not in cls.TYPE_MAPPING:
+ logger.warning(f"Type {allowed_type} is not mapped with a factory in {cls.__name__}.TYPE_MAPPING.")
+
+ @computed_field # type: ignore
+ @property
+ def value(self) -> Any:
+ return self.TYPE_MAPPING.get(self.type, None)
+
+ @computed_field # type: ignore
+ @property
+ def factory(self) -> Any:
+ return self.TYPE_FACTORY_MAPPING.get(self.type, None)
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(type='{self.type}')"
+
+ def __eq__(self, __value: object) -> bool:
+ return self.type.value == __value
+
+ @model_serializer
+ def _serialize_model(self) -> str:
+ return self.type.value
+
+
+class BlitzField(BaseModel):
+ class Config:
+ arbitrary_types_allowed = True
+
+ # Modifiers are used to define the properties of a field in the shortcut version of the blitz field
+ _unique_modifier: ClassVar[str] = "!"
+ _nullable_modifier: ClassVar[str] = "?"
+ _required_modifier: ClassVar[str] = "!"
+ _relationship_list_modifier: ClassVar[str] = "[]"
+
+ _field_name_shortcut_modifiers: ClassVar[str] = "".join(_unique_modifier)
+ _field_value_shortcut_modifiers: ClassVar[str] = "".join(
+ [
+ _nullable_modifier,
+ _required_modifier,
+ _relationship_list_modifier,
+ ]
+ )
+
+ # We store the raw values for writing them back in the blitz file
+ _raw_field_name: str | None = None
+ _raw_field_value: str | dict[str, Any] | None = None
+
+ type: BlitzType
+ default: Any = Field(_BlitzNullValue(), exclude=True)
+ foreign_key: str | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+ relationship: str | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+ relationship_list: bool | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+ back_populates: str | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+ nullable: bool | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+ unique: bool | _BlitzNullValue = Field(_BlitzNullValue(), exclude=True)
+
+ @field_validator("type", mode="before")
+ def _string_to_customtype(cls, v: str | BlitzType) -> BlitzType:
+ if isinstance(v, str):
+ return BlitzType(type=AllowedBlitzFieldTypes(v))
+ return v
+
+ # Need a fix in pydantic maybe use a custom method to serialize the model and not the @model_serializer
+ # @model_serializer
+ # def _serialize_model(self) -> dict[str, Any] | str:
+ # if isinstance(self._raw_field_value, dict):
+ # return self.model_dump()
+ # elif isinstance(self._raw_field_value, str):
+ # return self.model_shortcut_dump()
+ # else:
+ # raise ValueError(f"Type `{type(self._raw_field_value)}` not allowed")
+
+ @classmethod
+ def from_shortcut_version(cls, raw_field_name: str, raw_field_value: str) -> "BlitzField":
+ field_name = raw_field_name.strip(cls._field_name_shortcut_modifiers)
+ field_name_modifiers = raw_field_name[len(field_name) :]
+
+ field_value = raw_field_value.strip(cls._field_value_shortcut_modifiers)
+ field_value_modifiers = raw_field_value[len(field_value) :]
+
+ if cls._required_modifier in field_value_modifiers and cls._nullable_modifier in field_value_modifiers:
+ raise ValueError(f"Field `{field_name}` cannot be both required and nullable.")
+
+ if field_value in AllowedBlitzFieldTypes:
+ field_type = AllowedBlitzFieldTypes(field_value)
+ elif "." in field_value:
+ field_type = AllowedBlitzFieldTypes.foreign_key
+ else:
+ field_type = AllowedBlitzFieldTypes.relationship
+
+ return cls(
+ _raw_field_name=raw_field_name,
+ _raw_field_value=raw_field_value,
+ type=field_type, # type: ignore
+ nullable=cls._nullable_modifier in field_value_modifiers,
+ unique=cls._unique_modifier in field_name_modifiers,
+ default=None if cls._nullable_modifier in field_value_modifiers else _BlitzNullValue(),
+ foreign_key=field_value if field_type == AllowedBlitzFieldTypes.foreign_key else _BlitzNullValue(),
+ relationship=field_value if field_type == AllowedBlitzFieldTypes.relationship else _BlitzNullValue(),
+ relationship_list=(
+ cls._relationship_list_modifier in field_value_modifiers
+ if field_type == AllowedBlitzFieldTypes.relationship
+ else _BlitzNullValue()
+ ),
+ )
+
+ def model_shortcut_dump(self) -> str:
+ modifiers = []
+
+ if self.relationship_list is True:
+ modifiers.append(self._relationship_list_modifier)
+
+ if self.nullable is True:
+ modifiers.append(self._nullable_modifier)
+ elif self.nullable is False:
+ modifiers.append(self._required_modifier)
+
+ return f"{self.type}{''.join(modifiers)}"
diff --git a/blitz/models/blitz/file.py b/blitz/models/blitz/file.py
new file mode 100644
index 0000000..de7fc61
--- /dev/null
+++ b/blitz/models/blitz/file.py
@@ -0,0 +1,27 @@
+from typing import Any, ClassVar
+from pydantic import BaseModel, Field
+from blitz.models.blitz.config import BlitzAppConfig
+from blitz.models.blitz.resource import BlitzResourceConfig
+from pathlib import Path
+from enum import StrEnum
+
+class FileType(StrEnum):
+ JSON = "json"
+ YAML = "yaml"
+
+class BlitzFile(BaseModel):
+ """
+ The Blitz file is the configuration file for a Blitz app. It contains the BlitzAppConfig and a list of BlitzResourceConfig.
+ """
+
+ path: Path | None = Field(None, exclude=True)
+ file_type: FileType | None = Field(None, exclude=True)
+ config: BlitzAppConfig
+ resources_configs: list[BlitzResourceConfig] = Field(
+ [], serialization_alias="resources"
+ )
+ raw_file: dict = Field(exclude=True)
+
+ # def write(self) -> None:
+ # with open(self.path, "w") as blitz_file:
+ # blitz_file.write(self.model_dump_json)
diff --git a/blitz/models/blitz/resource.py b/blitz/models/blitz/resource.py
new file mode 100644
index 0000000..ba094f6
--- /dev/null
+++ b/blitz/models/blitz/resource.py
@@ -0,0 +1,61 @@
+from typing import Any
+from pydantic import BaseModel, field_validator
+from blitz.models.blitz.field import BlitzField
+from blitz.models.base import BaseResourceModel
+
+
+class BlitzResourceConfig(BaseModel):
+ """
+ The BlitzResourceConfig is the configuration of a BlitzResource. It contains the name of the resource, the allowed
+ methods and the fields. The fields can be a string or a BlitzField object.
+ If the fields is a string, we are reading it like a shortcut version of a BlitzField object.
+ """
+
+ name: str
+ allowed_methods: str = "CRUD"
+ fields: dict[str, BlitzField]
+
+ @field_validator("fields", mode="before")
+ def _string_to_fields(cls, v: dict[str, Any | dict[str, Any]]) -> dict[str, BlitzField]:
+ fields: dict[str, BlitzField] = {}
+ for raw_field_name, raw_field_value in v.items():
+ field_name = raw_field_name.strip(BlitzField._field_name_shortcut_modifiers)
+ # If the field values is a string, it can be an blitz type or a relationship related field
+ if isinstance(raw_field_value, str):
+ fields[field_name] = BlitzField.from_shortcut_version(raw_field_name, raw_field_value)
+ # Else if the field value is a dict, it must be a BlitzField object
+ elif isinstance(raw_field_value, dict):
+ fields[field_name] = BlitzField(
+ _raw_field_name=raw_field_name,
+ _raw_field_value=raw_field_value,
+ **raw_field_value,
+ )
+ else:
+ raise ValueError(f"Type `{type(raw_field_value)}` not allowed for field `{raw_field_name}`")
+ return fields
+
+ @property
+ def can_create(self) -> bool:
+ return "C" in self.allowed_methods
+
+ @property
+ def can_read(self) -> bool:
+ return "R" in self.allowed_methods
+
+ @property
+ def can_update(self) -> bool:
+ return "U" in self.allowed_methods
+
+ @property
+ def can_delete(self) -> bool:
+ return "D" in self.allowed_methods
+
+
+class BlitzResource(BaseModel):
+ """
+ The BlitzResource is the representation of a resource in Blitz. It contains the configuration used to generate the resource
+ and the SQLmodel class.
+ """
+
+ config: BlitzResourceConfig
+ model: type[BaseResourceModel]
diff --git a/blitz/models/utils.py b/blitz/models/utils.py
new file mode 100644
index 0000000..2b072da
--- /dev/null
+++ b/blitz/models/utils.py
@@ -0,0 +1,32 @@
+import enum
+from typing import Any
+
+
+class ContainsEnum(enum.EnumMeta):
+ """
+ Metaclass for Enums that provides a __contains__ method.
+
+ This metaclass allows checking if a value is a valid member of the Enum by using the 'in' operator.
+
+ Example:
+ >>> class MyEnum(Enum, metaclass=ContainsEnum):
+ ... VALUE1 = 'value1'
+ ... VALUE2 = 'value2'
+
+ >>> print('value1' in MyEnum) # Output: True
+ >>> print('value3' in MyEnum) # Output: False
+
+ """
+
+ def __new__(mcs, name: str, bases: tuple[Any], classdict: Any) -> "ContainsEnum":
+ return super().__new__(mcs, name, bases, classdict)
+
+ def __contains__(cls, item: Any) -> bool:
+ try:
+ instance: enum.Enum = cls(item)
+ if isinstance(instance.value, enum.auto):
+ return False
+ except ValueError:
+ return False
+ else:
+ return True
diff --git a/blitz/parser.py b/blitz/parser.py
new file mode 100644
index 0000000..067373f
--- /dev/null
+++ b/blitz/parser.py
@@ -0,0 +1,66 @@
+import json
+from pathlib import Path
+
+import yaml
+from typing import Any, NoReturn
+
+from blitz.models.blitz import BlitzFile
+
+
+def _get_data_from_json(file: Path) -> dict[str, Any]:
+ with open(file, "r") as f:
+ return dict(json.load(f))
+
+
+def _get_data_from_yaml(file: Path) -> dict[str, Any]:
+ with open(file, "r") as f:
+ return dict(yaml.safe_load(f))
+
+
+def _no_parser_for_suffix(file: Path) -> NoReturn:
+ raise ValueError(f"No parser for {file}")
+
+
+def _find_blitz_app_path(blitz_app_name: str) -> Path:
+ blitz_app_path = (Path(".") / Path(blitz_app_name)).absolute()
+ if not blitz_app_path.is_dir():
+ raise FileNotFoundError(f"Could not find a Blitz app in {blitz_app_path}.")
+ return blitz_app_path
+
+
+def _find_blitz_file_path(blitz_app_path: Path) -> Path:
+ yaml_blitz_file = blitz_app_path / "blitz.yaml"
+ json_blitz_file = blitz_app_path / "blitz.json"
+
+ if yaml_blitz_file.exists() and json_blitz_file.exists():
+ raise ValueError(
+ f"Found both a YAML and a JSON Blitz file in {blitz_app_path}."
+ )
+ if yaml_blitz_file.exists():
+ return yaml_blitz_file
+ elif json_blitz_file.exists():
+ return json_blitz_file
+ else:
+ raise FileNotFoundError(f"Could not find a Blitz file in {blitz_app_path}.")
+
+
+def parse_file(file_path: Path) -> BlitzFile:
+ blitz_file_fields = {
+ ".json": _get_data_from_json,
+ ".yaml": _get_data_from_yaml,
+ }.get(file_path.suffix, _no_parser_for_suffix)(file_path)
+ return BlitzFile(
+ path=file_path.absolute(),
+ file_type=file_path.suffix.removeprefix("."),
+ config=blitz_file_fields["config"],
+ resources_configs=blitz_file_fields["resources"],
+ raw_file=blitz_file_fields,
+ )
+
+
+def create_blitz_file_from_dict(blitz_file_content: dict) -> BlitzFile:
+ return BlitzFile(
+ config=blitz_file_content.get("config"),
+ resources_configs=blitz_file_content.get("resources"),
+ raw_file=blitz_file_content,
+ )
diff --git a/blitz/patch.py b/blitz/patch.py
new file mode 100644
index 0000000..d90822f
--- /dev/null
+++ b/blitz/patch.py
@@ -0,0 +1,39 @@
+def patch_fastapi_crudrouter() -> None:
+ import fastapi_crudrouter # type: ignore
+ from fastapi_crudrouter.core._types import T, PYDANTIC_SCHEMA # type: ignore
+ from typing import Any
+ from pydantic import __version__ as pydantic_version
+
+ PYDANTIC_MAJOR_VERSION = int(pydantic_version.split(".", maxsplit=1)[0])
+
+ def get_pk_type_patch(schema: type[PYDANTIC_SCHEMA], pk_field: str) -> Any:
+ try:
+ if PYDANTIC_MAJOR_VERSION >= 2:
+ return schema.model_fields[pk_field].annotation
+ else:
+ return schema.__fields__[pk_field].type_
+ except KeyError:
+ return int
+
+ from pydantic import create_model
+
+ def schema_factory_patch(schema_cls: type[T], pk_field_name: str = "id", name: str = "Create") -> type[T]:
+ """
+ Is used to create a CreateSchema which does not contain pk
+ """
+
+ fields = {f.name: (f.type_, ...) for f in schema_cls.__fields__.values() if f.name != pk_field_name}
+ if PYDANTIC_MAJOR_VERSION >= 2:
+ # pydantic 2.x
+ fields = {fk: (fv.annotation, ...) for fk, fv in schema_cls.model_fields.items() if fk != pk_field_name}
+ else:
+ # pydantic 1.x
+ fields = {f.name: (f.type_, ...) for f in schema_cls.__fields__.values() if f.name != pk_field_name}
+
+ name = schema_cls.__name__ + name
+ schema: type[T] = create_model(__model_name=name, **fields) # type: ignore
+ return schema
+
+ fastapi_crudrouter.core._utils.get_pk_type = get_pk_type_patch
+ fastapi_crudrouter.core._utils.schema_factory = schema_factory_patch
+ fastapi_crudrouter.core._base.schema_factory = schema_factory_patch
diff --git a/blitz/settings.py b/blitz/settings.py
new file mode 100644
index 0000000..b32687b
--- /dev/null
+++ b/blitz/settings.py
@@ -0,0 +1,28 @@
+import enum
+from functools import lru_cache
+import os
+
+from pydantic_settings import BaseSettings
+
+DOTENV = os.path.join(os.path.dirname(__file__), ".env")
+
+
+class DBTypes(enum.StrEnum):
+ SQLITE = "SQLITE"
+ MEMORY = "MEMORY"
+
+
+class Settings(BaseSettings):
+ class Config:
+ env_file = DOTENV
+ extra = "ignore"
+
+ BLITZ_PORT: int = 8100
+ DEFAULT_FILE: str = "blitz.json"
+ BLITZ_DB_TYPE: DBTypes = DBTypes.SQLITE
+ BLITZ_OPENAI_API_KEY: str = ""
+
+
+@lru_cache()
+def get_settings() -> Settings:
+ return Settings()
diff --git a/blitz/tools/__init__.py b/blitz/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/tools/erd.py b/blitz/tools/erd.py
new file mode 100644
index 0000000..21f393c
--- /dev/null
+++ b/blitz/tools/erd.py
@@ -0,0 +1,110 @@
+import base64
+import json
+from typing import Any
+import zlib
+
+from sqlalchemy import Column, MetaData, Table
+
+
+def determine_relationship_type(column: Column[Any], tables: dict[str, Table]) -> str:
+ """
+ Determine the relationship type based on a foreign key column.
+ Args:
+ column: The foreign key column which contains information about both
+ the referencing and referenced table.
+ tables: A dictionary of table names to Table objects.
+ Returns:
+ The relationship type as a string.
+ #TODO can be improved with an enum
+ Raises:
+ Exception: If the relationship type cannot be determined.
+ """
+ if not column.foreign_keys:
+ raise Exception(f"{column.table.name}.{column.name} does not have a foreign key reference.")
+
+ foreign_key = list(column.foreign_keys)[0]
+
+ current_side = "o|" if column.nullable else "||"
+ referenced_table = foreign_key.column.table
+
+ for _, other_table in tables.items():
+ for other_column in other_table.columns:
+ if other_column.foreign_keys:
+ if list(other_column.foreign_keys)[0].column.table.name == referenced_table.name:
+ if other_column.unique:
+ return f"|| -- {current_side}"
+ other_side = "}o" if other_column.nullable else "}|"
+ return f"{other_side} -- {current_side}"
+
+ raise Exception(f"Could not determine relationship type for {column.table.name}.{column.name}")
+
+
+def generate_mermaid_erd(metadata: MetaData) -> str:
+ """
+ Generate Mermaid ERD from SQLAlchemy Metadata.
+ #TODO improve documentation
+ """
+ erd = ["erDiagram"]
+
+ tables = metadata.tables
+
+ for table_name, table in tables.items():
+ columns = []
+ for col in table.columns:
+ col_desc = f"{col.name} {col.type}"
+ if col.primary_key:
+ col_desc += " PK"
+ if col.foreign_keys:
+ col_desc += " FK"
+ columns.append(col_desc)
+ columns_definition = "\n ".join(columns)
+ erd.append(f" {table_name} {{\n {columns_definition}\n }}")
+
+ visited_relations = set()
+ for table_name, table in tables.items():
+ for column in table.columns:
+ if column.foreign_keys:
+ referenced_table = list(column.foreign_keys)[0].column.table.name
+
+ relation = determine_relationship_type(column, tables)
+ relation_key = tuple(sorted([table_name, referenced_table]))
+
+ if relation and relation_key not in visited_relations:
+ erd.append(f' {table_name} {relation} {referenced_table}: " "')
+ visited_relations.add(relation_key)
+
+ return "\n".join(erd)
+
+
+def mermaid_serialize(erd: str) -> str:
+ """
+ Based on the mermaid-live-editor implementation
+ For data format:
+ https://github.com/mermaid-js/mermaid-live-editor/blob/414c5bb59b162dae0a2ecc775828069a50725ee8/src/lib/types.d.ts#L21
+ For data serialization:
+ https://github.com/mermaid-js/mermaid-live-editor/blob/414c5bb59b162dae0a2ecc775828069a50725ee8/src/lib/util/serde.ts#L19
+ """
+ data = json.dumps(
+ {
+ "code": erd,
+ "mermaid": '{ "theme": "dark" }',
+ "autoSync": False,
+ "updateDiagram": False,
+ "panZoom": True,
+ "pan": {"x": 0, "y": 0},
+ "zoom": 1,
+ }
+ )
+ compressed = zlib.compress(data.encode("utf-8"), level=9)
+ return base64.urlsafe_b64encode(compressed).decode("utf-8")
+
+
+def _mermaid_deserialize(state: str) -> dict[str, Any]:
+ """
+ For development purpose if you want to deserialize the data.
+ """
+ missing_padding = len(state) % 4
+ if missing_padding:
+ state += "=" * (4 - missing_padding)
+ data = base64.urlsafe_b64decode(state)
+ return dict(json.loads(zlib.decompress(data).decode("utf-8")))
diff --git a/blitz/ui/__init__.py b/blitz/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/ui/assets/blitz_logo.png b/blitz/ui/assets/blitz_logo.png
new file mode 100644
index 0000000..93e5f41
Binary files /dev/null and b/blitz/ui/assets/blitz_logo.png differ
diff --git a/blitz/ui/assets/blitz_logo_and_text.png b/blitz/ui/assets/blitz_logo_and_text.png
new file mode 100644
index 0000000..d5c358d
Binary files /dev/null and b/blitz/ui/assets/blitz_logo_and_text.png differ
diff --git a/blitz/ui/assets/blitz_logo_and_text_2.png b/blitz/ui/assets/blitz_logo_and_text_2.png
new file mode 100644
index 0000000..a1733a7
Binary files /dev/null and b/blitz/ui/assets/blitz_logo_and_text_2.png differ
diff --git a/blitz/ui/assets/favicon.ico b/blitz/ui/assets/favicon.ico
new file mode 100644
index 0000000..16ea446
Binary files /dev/null and b/blitz/ui/assets/favicon.ico differ
diff --git a/blitz/ui/assets/github_logo.png b/blitz/ui/assets/github_logo.png
new file mode 100644
index 0000000..6cb3b70
Binary files /dev/null and b/blitz/ui/assets/github_logo.png differ
diff --git a/blitz/ui/assets/github_white.png b/blitz/ui/assets/github_white.png
new file mode 100644
index 0000000..50b8175
Binary files /dev/null and b/blitz/ui/assets/github_white.png differ
diff --git a/blitz/ui/assets/swagger.png b/blitz/ui/assets/swagger.png
new file mode 100644
index 0000000..80fd698
Binary files /dev/null and b/blitz/ui/assets/swagger.png differ
diff --git a/blitz/ui/assets/swagger.svg b/blitz/ui/assets/swagger.svg
new file mode 100644
index 0000000..b94ae77
--- /dev/null
+++ b/blitz/ui/assets/swagger.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/blitz/ui/blitz_ui.py b/blitz/ui/blitz_ui.py
new file mode 100644
index 0000000..75bf0e0
--- /dev/null
+++ b/blitz/ui/blitz_ui.py
@@ -0,0 +1,92 @@
+from functools import lru_cache
+from blitz.app import BlitzApp
+from blitz.core import BlitzCore
+from blitz.settings import Settings, get_settings
+from blitz.tools.erd import generate_mermaid_erd
+
+
+# @lru_cache
+# def get_erd(app: BlitzApp) -> str:
+# return generate_mermaid_erd(app._base_resource_model.metadata)
+
+
+class BlitzUI:
+ def __init__(self, settings: Settings = get_settings()) -> None:
+ self.blitz_app: BlitzCore = BlitzCore()
+ self.apps = self.blitz_app.apps
+ self.preprompt = self._get_preprompt()
+
+ self.settings = settings
+ self.localhost_url = f"http://localhost:{settings.BLITZ_PORT}"
+ self.erd = None
+ self._current_project = None
+ self._current_app = None
+
+ @property
+ def current_project(self):
+ return self._current_project
+
+ @property
+ def current_app(self):
+ return self._current_app
+
+ @current_app.setter
+ def current_app(self, app: BlitzApp):
+ if not self.current_app:
+ self._current_app = app
+ self._current_project = app.name
+ self.erd = generate_mermaid_erd(app._base_resource_model.metadata)
+
+ # @current_project.setter
+ # def current_project(self, project):
+ # if project and project != self.current_project:
+ # self._current_project = project
+ # self._current_app = self.get_current_app()
+
+ def get_ressources(self):
+ columns = [
+ {
+ "name": "name",
+ "label": "Name",
+ "field": "name",
+ "required": True,
+ "align": "left",
+ "sortable": True,
+ },
+ {
+ "name": "allowed_methods",
+ "label": "Allowed Methods",
+ "field": "allowed_methods",
+ "sortable": True,
+ },
+ ]
+ rows = []
+ for ressource in self.current_app.resources:
+ rows.append(
+ {
+ "name": ressource.config.name,
+ "allowed_methods": ressource.config.allowed_methods,
+ }
+ )
+
+ return columns, rows
+
+ def get_current_app(self):
+ if self.current_project:
+ return self.blitz_app.get_app(self.current_project)
+
+ def add(self, blitz_app: BlitzCore) -> None:
+ self.blitz_app = blitz_app
+
+ def _get_preprompt(self):
+ with open("blitz/ui/preprompt.txt", "r") as f:
+ return f.read()
+
+ def reset_preprompt(self):
+ self.preprompt = self._get_preprompt()
+ print(self.preprompt)
+
+
+@lru_cache
+def get_blitz_ui() -> BlitzUI:
+ return BlitzUI()
diff --git a/blitz/ui/components/__init__.py b/blitz/ui/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/ui/components/gpt_chat_components.py b/blitz/ui/components/gpt_chat_components.py
new file mode 100644
index 0000000..6ad28cb
--- /dev/null
+++ b/blitz/ui/components/gpt_chat_components.py
@@ -0,0 +1,249 @@
+import json
+import re
+from typing import Any
+from nicegui import ui
+from pydantic import ValidationError
+from blitz.parser import create_blitz_file_from_dict
+from blitz.ui.components.json_editor import JsonEditorComponent
+
+
+from blitz.ui.components.header import DARK_PINK, MAIN_PINK
+import yaml
+
+
+class ResponseJSON:
+ def __init__(self, text: str) -> None:
+ self.text = text
+ self.json = self.extract_json(text)
+
+ self.is_valid_blitz_file = self.validate_blitz_file(self.json)
+
+ self.blitz_app_title = self._get_expansion_title(self.json)
+ self.color = self._get_color(self.is_valid_blitz_file)
+
+ self._expansion = None
+ self._expansion_is_open = True
+ self._dialog = None
+
+ @staticmethod
+ def _get_expansion_title(blitz_file: dict[str, str]) -> str:
+ name_part = blitz_file.get("config", {}).get("name", "Blitz App")
+ version = blitz_file.get("config", {}).get("version", "0.0.0")
+ return f"{name_part} v{version}"
+
+ @staticmethod
+ def _get_color(is_valid: bool) -> str:
+ if is_valid:
+ return "text-green"
+ return "text-red"
+
+ def validate_blitz_file(self, json: dict[str, Any]) -> bool:
+ try:
+ create_blitz_file_from_dict(json)
+ except ValidationError:
+ return False
+ else:
+ return True
+
+ @staticmethod
+ def extract_json(text):
+ json_pattern = r"```json([\s\S]*?)```"
+ return json.loads(re.search(json_pattern, text).group(1))
+
+ async def copy_code(self):
+ ui.run_javascript("navigator.clipboard.writeText(`" + str(self.json) + "`)")
+ ui.notify("Copied to clipboard", type="info", color="green")
+
+ def action_buttons(self):
+ with ui.row(wrap=False).classes("items-center"):
+ ui.button(
+ icon="content_copy",
+ color="transparent",
+ on_click=self.copy_code,
+ ).props("dense flat size=xm color=grey")
+ ui.button(
+ icon="file_download", color="transparent", on_click=self._dialog.open
+ ).props("dense flat size=xm color=grey")
+
+ def download_dialog(self):
+ with ui.dialog() as self._dialog, ui.card().classes("w-full px-4"):
+ if not self.is_valid_blitz_file:
+ self.invalid_blitz_file()
+ # with ui.expansion("Edit File", icon="edit").classes("w-full h-auto rounded-lg border-solid border overflow-hidden grow overflow-hidden"):
+ # JsonEditorComponent(self.json).render()
+ with ui.row().classes("w-full justify-end"):
+ ui.button(
+ "Export as JSON",
+ on_click=self._download_json,
+ ).props("flat")
+ ui.button("Export as YAML", on_click=self._download_yaml).props("flat")
+
+ def _download_json(self):
+ ui.download(
+ str.encode(json.dumps(self.json, indent=4)),
+ filename=self._get_filename("json"),
+ )
+
+ def _download_yaml(self):
+ ui.download(
+ str.encode(yaml.dump(self.json)), filename=self._get_filename("yaml")
+ )
+
+ def _get_filename(self, extension: str) -> str:
+ return f"{self.blitz_app_title.replace(' ', '_').replace('.', '_').lower()}.{extension}"
+
+ def invalid_blitz_file(self):
+ with ui.row().classes("items-center"):
+ ui.icon("error", color="red", size="sm")
+ ui.label("This is not a valid Blitz file.").classes("text-red")
+
+ def _toggle_expansion(self):
+ self._expansion_is_open = not self._expansion_is_open
+ self._expansion.value = self._expansion_is_open
+
+ @ui.refreshable
+ def render(self):
+ self.download_dialog()
+ with ui.row(wrap=False).classes("items-center w-full"):
+ with ui.expansion(
+ self.blitz_app_title,
+ icon="settings_suggest",
+ value=self._expansion_is_open,
+ on_value_change=self._toggle_expansion,
+ ).classes("rounded-lg border-solid border overflow-hidden grow").props(
+ f"overflow-hidden header-class={self.color}"
+ ) as self._expansion:
+ if not self.is_valid_blitz_file:
+ self.invalid_blitz_file()
+ ui.markdown(self.text)
+ self.action_buttons()
+
+
+class MarkdownResponse:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ @ui.refreshable
+ def render(self):
+ ui.markdown(self.text)
+
+
+class GPTChatComponent:
+ def __init__(
+ self,
+ label: str,
+ icon: str,
+ text: str,
+ avatar_color: str | None = None,
+ ) -> None:
+ self.response_json = None
+ self.label = label
+ self.text = text
+ self.icon = icon
+ self.avatar_color = avatar_color
+ self.text_components = None
+
+ @ui.refreshable
+ def render(self):
+ with ui.row(wrap=False).classes("w-full"):
+ ui.space().classes("w-1/3")
+ with ui.column().classes("justify-start w-2/3"):
+ with ui.row(wrap=False).classes("items-center w-full"):
+ with ui.avatar(color=self.avatar_color).props("size=sm"):
+ ui.icon(self.icon, size="xs", color="white")
+ ui.label(self.label).classes("font-bold")
+
+ if self.text_components:
+ for component in self.text_components:
+ with ui.element().classes("px-10 w-full"):
+ component.render()
+ else:
+ with ui.element().classes("px-10"):
+ ui.markdown(self.text)
+ ui.space().classes("w-1/3")
+
+ def as_gpt_dict(self) -> dict[str, str]:
+ raise NotImplementedError
+
+ def to_dict(self) -> dict[str, str]:
+ raise NotImplementedError
+
+
+class UserQuestion(GPTChatComponent):
+ LABEL = "You"
+ ICON = "person"
+ AVATAR_COLOR = "#a72bff"
+ ROLE = "user"
+
+ def __init__(self, text: str = "") -> None:
+ super().__init__(
+ label=self.LABEL, text=text, icon=self.ICON, avatar_color=self.AVATAR_COLOR
+ )
+
+ def as_gpt_dict(self) -> dict[str, str]:
+ return {"role": self.ROLE, "content": self.text}
+
+ def to_dict(self) -> dict[str, str]:
+ return self.as_gpt_dict()
+
+ @classmethod
+ def from_gpt_dict(cls, gpt_dict: dict[str, str]) -> "UserQuestion":
+ return cls(text=gpt_dict.get("content"))
+
+
+class GPTResponse(GPTChatComponent):
+ LABEL = "GPT"
+ ICON = "self_improvement"
+ AVATAR_COLOR = "#74aa9c"
+ ROLE = "assistant"
+
+ def __init__(self, text: str = "", text_is_finished: bool = False) -> None:
+ super().__init__(
+ label=self.LABEL, text=text, icon=self.ICON, avatar_color=self.AVATAR_COLOR
+ )
+ self._text_is_finished = None
+ self.text_is_finished = text_is_finished
+
+ def add(self, text: str) -> None:
+ self.text += text
+ self.render.refresh()
+
+ @property
+ def text_is_finished(self) -> bool:
+ return self._text_is_finished
+
+ @text_is_finished.setter
+ def text_is_finished(self, value: bool) -> None:
+ if value is True:
+ self._text_is_finished = value
+ self.text_components = self.split_response(self.text)
+ self.render.refresh()
+ else:
+ self._text_is_finished = value
+
+ def as_gpt_dict(self) -> dict[str, str]:
+ return {"role": self.ROLE, "content": self.text}
+
+ def to_dict(self) -> dict[str, str]:
+ dict_ = self.as_gpt_dict()
+ dict_["text_is_finished"] = self.text_is_finished
+ return dict_
+
+ @staticmethod
+ def split_response(text) -> list:
+ json_pattern = r"(```json[\s\S]*?```)"
+
+ # Search for JSON content in the Markdown text
+ components = []
+ for match in re.split(json_pattern, text):
+ if re.match(json_pattern, match):
+ components.append(ResponseJSON(match))
+ else:
+ components.append(MarkdownResponse(match))
+ return components
+
+ @classmethod
+ def from_gpt_dict(cls, gpt_dict: dict[str, str]) -> "UserQuestion":
+ return cls(
+ text=gpt_dict.get("content"), text_is_finished=gpt_dict["text_is_finished"]
+ )
diff --git a/blitz/ui/components/header.py b/blitz/ui/components/header.py
new file mode 100644
index 0000000..b51698b
--- /dev/null
+++ b/blitz/ui/components/header.py
@@ -0,0 +1,165 @@
+from contextlib import contextmanager
+from pathlib import Path
+
+from nicegui import Tailwind, ui, app
+
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+
+MAIN_PINK = "#cd87ff"
+DARK_PINK = "#a72bff"
+
+
+class HeaderMenuComponent:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ pass
+
+ def render(self):
+ ui.button(icon="menu").props("flat")
+
+
+class HeaderComponent:
+ def __init__(
+ self, title: str = "", blitz_ui: BlitzUI = get_blitz_ui(), drawer=None
+ ) -> None:
+ self.title = title
+ self.blitz_ui = blitz_ui
+
+ self.dark_mode = ui.dark_mode(value=True)
+ self.home_link = (
+ f"dashboard/projects/{blitz_ui.current_project}"
+ if blitz_ui.current_project
+ else "projects"
+ )
+ self.drawer = drawer
+ ui.add_head_html(
+ f""
+ )
+
+ ui.add_head_html(
+ f""
+ )
+
+ ui.colors(
+ primary="fffafa",
+ secondary="#a72bff",
+ accent="#111B1E",
+ positive="#53B689",
+ dark="#3e3e42",
+ )
+
+ def render(self):
+ with ui.header(bordered=True).classes(
+ "pl-1 pr-8 justify-between content-center h-16 backdrop-blur-sm"
+ ):
+
+ with ui.row().classes("items-center space-x-20 content-center my-auto"):
+
+ with ui.row().classes("items-center space-x-0 content-center "):
+ if self.drawer is not None:
+ ui.button(icon="menu", on_click=self.drawer.toggle).props(
+ "flat"
+ )
+ ui.icon(name="bolt", color=DARK_PINK, size="32px")
+ with ui.link(target=f"/projects/{self.blitz_ui.current_project}"):
+ ui.label("Blitz Dashboard")
+
+ with ui.row().classes("items-center justify-between content-center"):
+ with ui.link(
+ target=f"{self.blitz_ui.localhost_url}/projects"
+ ).classes("disabled"):
+ ui.tooltip("Multiple App management is coming soon")
+ ui.label("Projects")
+ with ui.link(target="/gpt"):
+ ui.label("GPT Builder")
+ with ui.link(
+ target="https://paperz-org.github.io/blitz/", new_tab=True
+ ):
+ ui.label("Documentation")
+ with ui.row().classes("items-center content-center my-auto"):
+ with ui.element():
+ ui.button(
+ icon="dark_mode",
+ on_click=lambda: self.dark_mode.set_value(True),
+ ).props("flat fab-mini color=black disabled").bind_visibility_from(
+ self.dark_mode, "value", value=False
+ )
+ ui.button(
+ icon="light_mode",
+ on_click=lambda: self.dark_mode.set_value(False),
+ ).props("flat fab-mini color=white disabled").bind_visibility_from(
+ self.dark_mode, "value", value=True
+ )
+ ui.tooltip("White mode is coming soon")
+ with ui.link(
+ target="https://github.com/Paperz-org/blitz", new_tab=True
+ ).classes(" w-8"):
+ ui.image("blitz/ui/assets/github_white.png").classes("w-8 ")
+
+
+class MenuLink:
+ def __init__(self, label: str, link: str, icon: str) -> None:
+ self.label = label
+ self.link = link
+ self.icon = icon
+
+ def render(self):
+
+ with ui.link(target=self.link).classes("w-full"), ui.button(
+ on_click=self.go_to
+ ).props("flat align=left").classes(
+ "px-4 hover:bg-slate-700 rounded-sm w-full"
+ ) as self.button:
+ ui.icon(name=self.icon, size="sm").props("flat").classes("pr-4")
+ ui.label(self.label)
+
+ def go_to(self):
+ ui.open(self.link)
+
+
+class FrameComponent:
+ def __init__(
+ self,
+ blitz_ui: BlitzUI = get_blitz_ui(),
+ show_drawer: bool = True,
+ drawer_open: bool = True,
+ ) -> None:
+ self.blitz_ui = blitz_ui
+ self.current_project = blitz_ui.current_project
+ self.show_drawer = show_drawer
+ self.drawer_open = drawer_open
+
+ # Only for declarative
+ self.drawer = None
+
+ def left_drawer(self):
+ with ui.left_drawer(
+ value=self.drawer_open, fixed=True, bottom_corner=True
+ ).props("width=200").classes("px-0 bg-[#14151a]") as self.drawer:
+
+ MenuLink(
+ "Dashboard", f"/projects/{self.current_project}", "dashboard"
+ ).render()
+ MenuLink(
+ "Admin",
+ f"{self.blitz_ui.localhost_url}/admin/",
+ "table_chart",
+ ).render()
+ MenuLink(
+ "Swagger", f"/projects/{self.current_project}/swagger", "api"
+ ).render()
+ MenuLink(
+ "Blitz File",
+ f"/projects/{self.current_project}/blitz-file",
+ "article",
+ ).render()
+ MenuLink(
+ "Diagram",
+ f"/projects/{self.current_project}/diagram",
+ "account_tree",
+ ).render()
+ MenuLink("Logs", f"/projects/{self.current_project}/logs", "list").render()
+
+ def render(self):
+ if self.show_drawer and self.current_project is not None:
+ self.left_drawer()
+ HeaderComponent(drawer=self.drawer).render()
diff --git a/blitz/ui/components/json_editor.py b/blitz/ui/components/json_editor.py
new file mode 100644
index 0000000..d236782
--- /dev/null
+++ b/blitz/ui/components/json_editor.py
@@ -0,0 +1,120 @@
+import json
+from typing import Any
+from nicegui import ui, app
+from pydantic import ValidationError
+import yaml
+from blitz.models.blitz.file import FileType
+from blitz.parser import create_blitz_file_from_dict, parse_file
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from blitz.ui.components.header import DARK_PINK, MAIN_PINK
+
+
+class JsonEditorComponent:
+ primary_color = DARK_PINK
+ highlight_color = MAIN_PINK
+
+ def __init__(
+ self,
+ content: dict[str, Any],
+ ) -> None:
+ self.content = content
+
+ def render(self):
+ ui.json_editor({"content": {"json": self.content}, "readOnly": False}).classes(
+ "w-full jse-theme-dark rounded-lg"
+ ).style(
+ f"--jse-theme-color: {self.primary_color}; --jse-theme-color-highlight: {self.highlight_color}"
+ )
+
+
+class BlitzFileEditorComponent:
+ primary_color = DARK_PINK
+ highlight_color = MAIN_PINK
+
+ def __init__(
+ self,
+ content: dict[str, Any],
+ mode: str = "text",
+ blitz_ui: BlitzUI = get_blitz_ui(),
+ ) -> None:
+ self.blitz_ui = blitz_ui
+ self._original_content = content
+ if app.storage.user.get("blitz_file_content") is not None:
+ self.content = app.storage.user.get("blitz_file_content")
+ else:
+ self.content = content
+ self.mode = mode
+ self._read_only = True
+
+ async def get_data(self) -> None:
+ raw_content: dict = await self.editor.run_editor_method("get")
+ try:
+ json_content = json.loads(raw_content.get("text"))
+ except (json.JSONDecodeError, TypeError):
+ return
+ self.content = json_content
+ app.storage.user["blitz_file_content"] = self.content
+
+ def enable_editor(self):
+ self._read_only = not self._read_only
+ self.editor.run_editor_method("updateProps", {"readOnly": self._read_only})
+
+ def reset_content(self):
+ self.content = self._original_content
+ self.editor.run_editor_method("update", {"json": self.content})
+ app.storage.user["blitz_file_content"] = self.content
+ ui.notify("Content Reset", type="positive")
+
+ def validate(self):
+ try:
+ create_blitz_file_from_dict(self.content)
+ except ValidationError:
+ ui.notify("Invalid Blitz File", type="negative")
+ else:
+ ui.notify("Valid Blitz File", type="positive")
+
+ def save(self):
+ try:
+ create_blitz_file_from_dict(self.content)
+ except ValidationError:
+ ui.notify("Invalid Blitz File", type="negative")
+ return
+ try:
+ with open(self.blitz_ui.current_app.file.path, "w") as f:
+ if self.blitz_ui.current_app.file.file_type == FileType.JSON:
+ f.write(json.dumps(self.content, indent=4))
+ elif self.blitz_ui.current_app.file.file_type == FileType.YAML:
+ f.write(yaml.dump(self.content, indent=4))
+ except Exception:
+ ui.notify("Error While Saving File", type="negative")
+ else:
+ ui.notify("Content Saved", type="positive")
+
+ def render(self):
+ with ui.row().classes(
+ "w-full justify-between align-center p-4 rounded-lg border"
+ ):
+ with ui.row().classes("justify-between"):
+ ui.switch("Edit BlitzFile", on_change=self.enable_editor)
+ ui.button(
+ "Reset", on_click=self.reset_content, icon="restart_alt"
+ ).props("flat")
+ with ui.row().classes("justify-between"):
+ ui.button("Validate", on_click=self.validate, icon="verified").props(
+ "flat"
+ )
+ ui.button("Save", on_click=self.save, icon="save").props("flat")
+ self.editor = (
+ ui.json_editor(
+ {
+ "content": {"json": self.content},
+ "readOnly": self._read_only,
+ "mode": self.mode,
+ },
+ on_change=self.get_data,
+ )
+ .classes("w-full jse-theme-dark rounded-lg")
+ .style(
+ f"--jse-theme-color: {self.primary_color}; --jse-theme-color-highlight: {self.highlight_color}"
+ )
+ )
diff --git a/blitz/ui/components/logger.py b/blitz/ui/components/logger.py
new file mode 100644
index 0000000..d89ab54
--- /dev/null
+++ b/blitz/ui/components/logger.py
@@ -0,0 +1,29 @@
+import logging
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from nicegui import ui
+from blitz.api.logs import InterceptHandler
+
+
+class LogElementHandler(InterceptHandler):
+ """A logging handler that emits messages to a log element."""
+
+ def __init__(self, element: ui.log, level: int = logging.NOTSET) -> None:
+ self.element = element
+ super().__init__(level)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ if record.name != "uvicorn.access.ui":
+ self.element.push(record.getMessage())
+ except Exception:
+ self.handleError(record)
+
+
+class LogComponent:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+ self._logger = logging.getLogger("uvicorn.access")
+
+ def render(self):
+ log = ui.log(max_lines=None).classes("w-full h-64 text-sm")
+ self._logger.addHandler(LogElementHandler(log))
diff --git a/blitz/ui/components/status.py b/blitz/ui/components/status.py
new file mode 100644
index 0000000..c822cad
--- /dev/null
+++ b/blitz/ui/components/status.py
@@ -0,0 +1,42 @@
+from nicegui import ui
+
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from httpx import AsyncClient
+
+
+class StatusComponent:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+ self.app = self.blitz_ui.current_app
+ self.api_up = False
+ self.admin_up = False
+ ui.timer(10.0, self.set_status)
+
+ async def _is_api_up(self):
+ async with AsyncClient() as client:
+ response = await client.get(f"{self.blitz_ui.localhost_url}/api")
+ return response.status_code == 200
+
+ async def _is_admin_up(self):
+ async with AsyncClient() as client:
+ response = await client.get(f"{self.blitz_ui.localhost_url}/admin/")
+ return response.status_code == 200
+
+ async def set_status(self):
+ self.api_up = await self._is_api_up()
+ self.admin_up = await self._is_admin_up()
+ self.render.refresh()
+
+ @ui.refreshable
+ def render(self):
+ with ui.grid(rows=2, columns=2).classes("gap-4"):
+ ui.label(f"API:").classes("text-lg font-bold")
+ if self.api_up:
+ ui.icon(name="check_circle").classes("text-green-500")
+ else:
+ ui.icon(name="error").classes("text-red-500")
+ ui.label(f"Admin:").classes("text-lg font-bold")
+ if self.admin_up:
+ ui.icon(name="check_circle").classes("text-green-500")
+ else:
+ ui.icon(name="error").classes("text-red-500")
diff --git a/blitz/ui/domains/__init__.py b/blitz/ui/domains/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/ui/main.py b/blitz/ui/main.py
new file mode 100644
index 0000000..425f79e
--- /dev/null
+++ b/blitz/ui/main.py
@@ -0,0 +1,106 @@
+import logging
+from typing import TYPE_CHECKING, Annotated
+
+from blitz.app import BlitzApp
+from blitz.core import BlitzCore
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from blitz.ui.pages.admin import AdminPage
+from blitz.ui.pages.blitz_file import BlitzFilePage
+from blitz.ui.pages.dashboard import DashboardPage
+from blitz.ui.pages.diagram import MermaidPage
+from blitz.ui.pages.gpt_builder import AskGPTPage
+from blitz.ui.pages.log import LogPage
+from blitz.ui.pages.projects import HomePage
+from blitz.ui.pages.swagger import SwaggerPage
+from pathlib import Path
+from nicegui import ui
+
+from nicegui import ui
+from pydantic import BaseModel
+from nicegui import app, ui
+
+from blitz.ui.components.header import FrameComponent, HeaderComponent
+
+
+from fastapi import Depends, FastAPI, Request
+
+from nicegui import app, ui
+
+if TYPE_CHECKING:
+ from blitz.api.blitz_api import BlitzAPI
+
+
+async def _post_dark_mode(request: Request) -> None:
+ print("dark mode")
+ app.storage.browser["dark_mode"] = (await request.json()).get("value")
+
+
+@ui.page("/projects/{uuid}", title="Dashboard")
+def dashboard_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ DashboardPage().render_page()
+ FrameComponent().render()
+
+
+@ui.page("/projects/{uuid}/diagram", title="Diagram")
+def diagram_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ MermaidPage().render_page()
+ FrameComponent().render()
+
+
+@ui.page("/projects/{uuid}/blitz-file", title="Blitz File")
+def blitz_file_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ BlitzFilePage().render_page()
+ FrameComponent().render()
+
+
+@ui.page("/projects/{uuid}/swagger", title="Swagger")
+def swagger_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ SwaggerPage().render_page()
+ FrameComponent().render()
+
+
+@ui.page("/projects/{uuid}/logs")
+def log_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ ui.page_title("Logs")
+ LogPage().render_page()
+ FrameComponent().render()
+
+
+# @ui.page("/projects/{uuid}/admin")
+# def log_page(uuid: str, blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+# ui.page_title("Admin")
+# AdminPage().render_page()
+# FrameComponent(drawer_open=False).render()
+
+
+@ui.page("/gpt")
+def ask_gpt_page(blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+ ui.page_title("GPT Builder")
+ AskGPTPage().render_page()
+ FrameComponent(show_drawer=False).render()
+
+
+# @ui.page("/projects")
+# def projects_page(blitz_ui: Annotated[BlitzUI, Depends(get_blitz_ui)]) -> None:
+# ui.page_title("Projects")
+# HomePage().render_page()
+# FrameComponent(show_drawer=False).render()
+
+
+def init_ui(
+ blitz_api: "BlitzAPI",
+ mount_path: str = "/dashboard",
+ storage_secret: str = "secret",
+ title: str = "Dashboard",
+) -> None:
+ logging.getLogger("niceGUI").setLevel(logging.WARNING)
+ blitz_ui = get_blitz_ui()
+ blitz_ui.current_app = blitz_api.blitz_app
+ ui.run_with(
+ app=blitz_api,
+ title=title,
+ dark=True,
+ mount_path=mount_path,
+ storage_secret=storage_secret,
+ favicon=Path("blitz/ui/assets/favicon.ico"),
+ )
diff --git a/blitz/ui/pages/__init__.py b/blitz/ui/pages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blitz/ui/pages/admin.py b/blitz/ui/pages/admin.py
new file mode 100644
index 0000000..25dfb7b
--- /dev/null
+++ b/blitz/ui/pages/admin.py
@@ -0,0 +1,33 @@
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from nicegui import ui
+
+
+class AdminPage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+
+ def resize_iframe(self):
+ ui.run_javascript(
+ """
+ var iframe = document.querySelector('iframe');
+ var resizeIframe = function() {
+ iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px';
+ };
+ var navList = iframe.getElementById('navList');
+ if (navList) {
+ console.log('hello')
+ var lastNavItem = navList.lastElementChild;
+ if (lastNavItem) {
+ lastNavItem.style.pointerEvents = 'none';
+ lastNavItem.style.color = 'gray';
+ }
+ }
+ };
+ """
+ )
+
+ def render_page(self):
+ self.resize_iframe()
+ ui.element("iframe").props(
+ f"src={self.blitz_ui.localhost_url}/admin/ frameborder=0 onload=resizeIframe()"
+ ).classes("w-full rounded-sm bg-white h-screen overflow-hidden")
diff --git a/blitz/ui/pages/blitz_file.py b/blitz/ui/pages/blitz_file.py
new file mode 100644
index 0000000..9bb80fd
--- /dev/null
+++ b/blitz/ui/pages/blitz_file.py
@@ -0,0 +1,12 @@
+from nicegui import ui
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from blitz.ui.components.json_editor import BlitzFileEditorComponent
+
+
+class BlitzFilePage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui(), project: str = None) -> None:
+ self.blitz_ui = blitz_ui
+ self.blitz_app = blitz_ui.current_app
+
+ def render_page(self):
+ BlitzFileEditorComponent(self.blitz_app.file.raw_file).render()
diff --git a/blitz/ui/pages/dashboard.py b/blitz/ui/pages/dashboard.py
new file mode 100644
index 0000000..2d4897a
--- /dev/null
+++ b/blitz/ui/pages/dashboard.py
@@ -0,0 +1,55 @@
+from nicegui import ui
+
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+import uuid
+from httpx import AsyncClient
+
+from blitz.ui.components.logger import LogComponent
+from blitz.ui.components.status import StatusComponent
+
+class ProjectDetailComponent:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+ self.app = self.blitz_ui.current_app
+
+ def render(self) -> None:
+ with ui.row().classes("w-full justify-between items-center"):
+ ui.label(f"{self.app.file.config.name}").classes(
+ "text-xl font-bold"
+ )
+ ui.label(f"Version: {self.app.file.config.version}").classes(
+ "font-bold text-sm"
+ )
+ ui.separator()
+ ui.label(f"Project Path: {self.app.path}").classes("text-sm")
+ ui.label(
+ f"Description: {self.app.file.config.description}"
+ ).classes("text-sm font-normal")
+
+class DashboardPage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+ self.app = self.blitz_ui.current_app
+ self.columns, self.rows = self.blitz_ui.get_ressources()
+
+ def render_page(self) -> None:
+ with ui.element("div").classes("w-full h-full flex flex-row justify-center"):
+ with ui.column().classes("w-2/3 h-full border rounded-lg border-gray-300"):
+ with ui.expansion("Project", value=True, icon="info").classes(
+ "w-full text-bold text-2xl "
+ ):
+ ProjectDetailComponent().render()
+ with ui.expansion("Models", value=True, icon="help_outline").classes(
+ "w-full text-bold text-2xl"
+ ):
+ ui.table(
+ columns=self.columns, rows=self.rows, row_key="name"
+ ).classes("w-full no-shadow")
+ with ui.expansion(
+ "Status", value=True, icon="health_and_safety"
+ ).classes("w-full text-bold text-2xl"):
+ StatusComponent().render()
+ with ui.expansion("Logs", value=False, icon="list").classes(
+ "w-full text-bold text-2xl"
+ ):
+ LogComponent().render()
diff --git a/blitz/ui/pages/diagram.py b/blitz/ui/pages/diagram.py
new file mode 100644
index 0000000..f7ba29e
--- /dev/null
+++ b/blitz/ui/pages/diagram.py
@@ -0,0 +1,46 @@
+from nicegui import ui
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+
+
+class MermaidPage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui(), project: str = None) -> None:
+ self.project = project
+ self.blitz_ui = blitz_ui
+ self._width = 100
+
+ def remove_style(self):
+ ui.run_javascript(
+ """
+ var svg = document.querySelector('svg');
+ svg.removeAttribute("style");
+ """
+ )
+
+ def zoom_svg(self):
+ self._width += 50
+ self.remove_style()
+ ui.run_javascript(
+ f"""
+ var svg = document.querySelector('svg');
+ svg.setAttribute("width", "{self._width}%");
+ """
+ )
+
+ def unzoom_svg(self):
+ self._width -= 50
+ if self._width < 100:
+ self._width = 100
+ self.remove_style()
+ ui.run_javascript(
+ f"""
+ var svg = document.querySelector('svg');
+ svg.setAttribute("width", "{self._width}%");
+ """
+ )
+
+ def render_page(self):
+ with ui.scroll_area().classes("w-full h-screen justify-center content-center"):
+ ui.mermaid(self.blitz_ui.erd)
+ with ui.footer().classes("w-full justify-start "):
+ ui.button(icon="zoom_in", on_click=self.zoom_svg).classes("borderrounded-sm").props("flat")
+ ui.button(icon="zoom_out", on_click=self.unzoom_svg).classes("border rounded-sm").props("flat")
diff --git a/blitz/ui/pages/gpt_builder.py b/blitz/ui/pages/gpt_builder.py
new file mode 100644
index 0000000..db8af97
--- /dev/null
+++ b/blitz/ui/pages/gpt_builder.py
@@ -0,0 +1,444 @@
+from functools import lru_cache
+from typing import AsyncGenerator, Callable
+
+from nicegui import ui, app
+from nicegui.events import KeyEventArguments
+from openai import APIConnectionError, AsyncOpenAI, AuthenticationError
+
+from blitz.settings import get_settings
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from blitz.ui.components.gpt_chat_components import (
+ GPTChatComponent,
+ GPTResponse,
+ UserQuestion,
+)
+
+DEV_TEXT = """Sure! Here is a sample blitz_file with randomly generated models and fields:
+
+```json
+{
+ "config": {
+ "name": "Random App",
+ "description": "A randomly generated Blitz app",
+ "version": "1.0.0"
+ },
+ "models": [
+ {
+ "name": "User",
+ "fields": {
+ "name": "str",
+ "age": "int",
+ "email": "str",
+ "address": "str"
+ }
+ },
+ {
+ "name": "Product",
+ "fields": {
+ "name": "str",
+ "description": "str",
+ "price": "float",
+ "quantity": "int"
+ }
+ },
+ {
+ "name": "Order",
+ "fields": {
+ "user_id": "User.id",
+ "product_id": "Product.id",
+ "quantity": "int",
+ "total_price": "float"
+ }
+ }
+ ]
+}
+```
+
+Please note that this blitz_file is randomly generated and may not have any specific meaning or logic."""
+
+
+class GPTClient:
+ def __init__(
+ self, api_key: str, model: str = "gpt-3.5-turbo", pre_prompt: str | None = None
+ ) -> None:
+ self.model = model
+ self._api_key = api_key
+ self.pre_prompt = pre_prompt
+ self.client = self._get_client(api_key=api_key) if api_key else None
+
+ @staticmethod
+ def _get_client(api_key: str):
+ return AsyncOpenAI(api_key=api_key)
+
+ def _add_preprompt(self, messages: list[dict[str, str]]):
+ messages.insert(
+ 0,
+ {
+ "role": "system",
+ "name": "BlitzUI",
+ "content": self.pre_prompt,
+ },
+ )
+ return messages
+
+ def refresh_client(self, api_key: str) -> None:
+ self._api_key = api_key
+ self.client = self._get_client(api_key=api_key)
+
+ async def stream(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]:
+ if self.pre_prompt:
+ messages = self._add_preprompt(messages)
+ return await self.client.chat.completions.create(
+ model=self.model,
+ messages=messages,
+ stream=True,
+ )
+
+ async def list_models(self):
+ return await self.client.models.list()
+
+
+@lru_cache
+def get_gpt_client() -> GPTClient:
+ return GPTClient(api_key=get_settings().BLITZ_OPENAI_API_KEY)
+
+
+class AskGPTPage:
+ def __init__(
+ self,
+ gpt_client: GPTClient = get_gpt_client(),
+ blitz_ui: BlitzUI = get_blitz_ui(),
+ ) -> None:
+ self.gpt_client = gpt_client
+ self.gpt_client.pre_prompt = blitz_ui.preprompt
+
+ self.blitz_ui = blitz_ui
+ self._gpt_client_error = False
+ self.gpt_request: str = ""
+
+ if messages := app.storage.user.get("gpt_messages", []):
+ self.gpt_messages: list[GPTChatComponent] = [
+ (
+ UserQuestion.from_gpt_dict(message)
+ if message["role"] == "user"
+ else GPTResponse.from_gpt_dict(message)
+ )
+ for message in messages
+ ]
+ else:
+ self.gpt_messages: list[GPTChatComponent] = []
+
+ # Theses variables are the result of `ui.state(False)` defined in the chat_area method
+ # because it need to be in a ui.refreshable component
+ self.thinking: bool
+ self.set_thinking: Callable[[bool], None]
+
+ # Here because otherwise when the ui refresh the scroll is reset to top (???)
+ self._scroll_area = ui.scroll_area().classes("flex-grow")
+
+ # Only declarative
+ self.settings_dialog = None
+
+ @property
+ def can_send_request(self) -> bool:
+ return self.gpt_request != "" or self.thinking is True
+
+ @property
+ def gpt_client_is_valid(self) -> bool:
+ return self.gpt_client.client is not None and self._gpt_client_error is False
+
+ @property
+ def gpt_client_error(self):
+ return self._gpt_client_error
+
+ def render_page(self):
+ # Allow the full size of the scrollable zone
+ ui.query(".q-page").classes("flex")
+ ui.query(".nicegui-content").classes("w-full")
+
+ # Allow the usage of cmd + enter to send the request
+ ui.keyboard(on_key=self.handle_key, ignore=[])
+
+ self.settings_dialog = ChatSettings(self.gpt_client)
+ self.settings_dialog.render()
+ self.delete_conversation()
+
+ # All the components of the chat
+ self.chat_area()
+
+ # The footer with the input and the send button
+ self.footer()
+
+ def refresh_dialog(self, api_key_input: str):
+ self.gpt_client.refresh_client(api_key=api_key_input)
+ self.gpt_client_error = False
+ self.dialog.refresh()
+
+ def footer(self):
+ with ui.footer().classes("items-center space-y-0 pt-0 justify-center px-5"):
+ with ui.grid(columns=10).classes("w-full items-center gap-5"):
+ with ui.button(on_click=self.delete_conversation_dialog.open).props(
+ "flat size=sm"
+ ).classes("justify-self-start"):
+ ui.icon("delete_outline", color="grey-8", size="md").props(
+ "fab-mini"
+ )
+ with ui.button(on_click=self.open_settings).props("flat").classes(
+ "justify-self-end"
+ ):
+ ui.icon("settings", color="grey-6", size="md").props("fab-mini")
+
+ with ui.row(wrap=False).classes(
+ "w-full items-center rounded-lg pl-2 border-solid border col-start-3 col-span-6"
+ ):
+ ui.textarea(on_change=lambda: self.ask_button.refresh()).props(
+ "borderless autogrow standout clearable dense"
+ ).classes("flex-grow w-auto ").bind_value(self, "gpt_request")
+ self.ask_button()
+ ui.space().classes("col-span-2")
+
+ ui.label(
+ "ChatGPT can make mistakes. Consider checking important information."
+ ).classes("text-xs text-gray-500 w-full text-center")
+
+ def delete_conversation(self):
+ with ui.dialog() as self.delete_conversation_dialog, ui.card().classes(
+ "no-shadow"
+ ):
+ ui.label("Are you sure you want to delete this conversation?")
+ with ui.row().classes("w-full items-center"):
+ ui.button(
+ "Cancel", on_click=self.delete_conversation_dialog.close
+ ).props("flat")
+ ui.button("Delete", on_click=self._handle_delete_conversation).props(
+ "flat"
+ )
+
+ def _handle_delete_conversation(self):
+ self.remove_conversation()
+ self.delete_conversation_dialog.close()
+
+ def open_settings(self):
+ self.settings_dialog.refresh()
+ self.settings_dialog.open()
+
+ def remove_conversation(self):
+ self.gpt_messages = []
+ app.storage.user["gpt_messages"] = []
+ self.chat_area.refresh()
+
+ @ui.refreshable
+ def chat_area(self):
+ self.thinking, self.set_thinking = ui.state(False)
+
+ # Here because otherwise when the ui refresh the scroll is reset to top (???)
+ # Same as comment in __init__, we need to clean the scroll_area otherwise all the message are duplicated
+ self._scroll_area.clear()
+
+ with self._scroll_area:
+ for message in self.gpt_messages:
+ message.render()
+
+ @ui.refreshable
+ def ask_button(self):
+ ask_button = (
+ ui.button(on_click=self.ask_button_trigger)
+ .props("flat")
+ .bind_enabled_from(self, "can_send_request")
+ )
+
+ with ask_button:
+ if not self.thinking:
+ ui.icon("send", color="#a72bff").props("fab-mini")
+ else:
+ ui.icon("stop_circle", color="#a72bff").props("fab-mini")
+
+ async def handle_key(self, e: KeyEventArguments):
+ if e.modifiers.meta and e.key.enter and self.can_send_request:
+ await self.ask_button_trigger()
+
+ async def ask_button_trigger(self):
+ self.set_thinking(not self.thinking)
+ if self.thinking:
+ self.gpt_messages.append(UserQuestion(self.gpt_request))
+ self.chat_area.refresh()
+ try:
+ await self._handle_ask_event()
+ except AuthenticationError:
+ self.gpt_client_error = True
+
+ self.set_thinking(False)
+ else:
+ self.gpt_request = ""
+
+ app.storage.user["gpt_messages"] = [
+ message.to_dict() for message in self.gpt_messages
+ ]
+ self.ask_button.refresh()
+
+ async def _handle_ask_event(self):
+ gpt_response = GPTResponse()
+ self.gpt_messages.append(gpt_response)
+
+ self.chat_area.refresh()
+ self.gpt_request = ""
+
+ async for i in self.ask_gpt():
+ gpt_response.add(i)
+ self._scroll_area.scroll_to(percent=100)
+ gpt_response.text_is_finished = True
+
+ async def ask_gpt(self):
+ messages = [
+ {
+ "role": "system",
+ "name": "BlitzUI",
+ "content": self.blitz_ui.preprompt,
+ }
+ ]
+ messages.extend([message.as_gpt_dict() for message in self.gpt_messages])
+
+ stream = await self.gpt_client.stream(messages=messages)
+
+ async for chunk in stream:
+ if self.thinking is False:
+ break
+ if chunk.choices[0].delta.content is not None:
+ yield chunk.choices[0].delta.content
+
+
+class ChatSettings:
+ def __init__(
+ self, gpt_client: GPTClient, blitz_ui: BlitzUI = get_blitz_ui()
+ ) -> None:
+ self.gpt_client = gpt_client
+ self.dialog = ui.dialog().props(
+ "maximized transition-show=slide-up transition-hide=slide-down"
+ )
+ self.blitz_ui = blitz_ui
+ self.quit_dialog = ui.dialog(value=False)
+
+ @ui.refreshable
+ def render(self):
+ """Render the settings dialog"""
+ with self.dialog, ui.card().classes("w-full px-4"):
+ self.quit_modal()
+ self.header()
+ ui.label("OpenAI").classes("text-xl font-bold")
+ self.openai_settings()
+ ui.label("Pre Prompt").classes("text-xl font-bold")
+ self.pre_prompt_editor()
+
+ def api_key_input_component(self):
+ """Create the api key input component"""
+ self.api_key_input = (
+ ui.input(
+ "OpenAI API Key",
+ value=self.gpt_client._api_key,
+ placeholder="sk-...",
+ password=True,
+ password_toggle_button=True,
+ )
+ .props("borderless standout dense")
+ .classes("grow rounded-lg px-2 border-solid border")
+ )
+
+ def header(self):
+ """Render the header of the settings dialog"""
+ with ui.row().classes("w-full items-center justify-center"):
+ ui.button(icon="close", on_click=self.close).props("flat")
+ ui.label("Chat Settings").classes("text-2xl font-bold grow text-center")
+ ui.button("Save", icon="save", on_click=self.save).classes(
+ "text-color-black"
+ ).props("flat").bind_enabled_from(self, "settings_has_changed")
+
+ @ui.refreshable
+ def openai_settings(self):
+ """Render the openai settings"""
+ with ui.row().classes("w-full items-center"):
+ self.model_select = (
+ ui.select(
+ {"gpt-3.5-turbo": "3.5 Turbo", "gpt-4": "4"},
+ label="Model",
+ value=self.gpt_client.model,
+ )
+ .props("borderless standout dense")
+ .classes("w-32 rounded-lg px-2 border-solid border")
+ )
+ self.api_key_input_component()
+ ui.button("Check API KEY", on_click=self.validate_api_key).props("outline")
+ ui.space()
+
+ @ui.refreshable
+ def pre_prompt_editor(self):
+ with ui.row().classes("w-full items-center"):
+ ui.button("Reset Pre-Prompt", on_click=self.reset_preprompt).props(
+ "outline"
+ )
+ switch = ui.switch("Edit Pre-Prompt", value=False)
+ self.preprompt = (
+ ui.textarea(label="Pre-Prompt", value=self.blitz_ui.preprompt)
+ .classes("w-full rounded-lg px-2 border-solid border")
+ .props("borderless autogrow")
+ .bind_enabled_from(switch, "value")
+ )
+
+ def close(self):
+ self.dialog.close()
+
+ def open(self):
+ self.dialog.open()
+
+ @property
+ def settings_has_changed(self):
+ return (
+ self.gpt_client.model != self.model_select.value
+ or self.blitz_ui.preprompt != self.preprompt.value
+ or self.gpt_client._api_key != self.api_key_input.value
+ )
+
+ def refresh(self):
+ self.openai_settings.refresh()
+ self.pre_prompt_editor.refresh()
+
+ def reset_preprompt(self):
+ self.blitz_ui.reset_preprompt()
+ self.pre_prompt_editor.refresh()
+
+ def quit_modal(self):
+ with self.quit_dialog, ui.card():
+ with ui.row().classes("w-full items-center"):
+ ui.button(icon="close", on_click=self.quit_dialog.close).props("flat")
+ ui.label("Some changes wasn't saved.").classes("font-bold")
+ with ui.row().classes("w-full items-center"):
+ ui.button("Discard changes", on_click=self.quit).props("flat")
+ ui.button("Save", on_click=self.save).props("flat")
+
+ def close(self):
+ if self.settings_has_changed:
+ self.quit_dialog.open()
+ else:
+ self.quit()
+
+ def quit(self):
+ if self.quit_dialog.value:
+ self.quit_dialog.close()
+ self.dialog.close()
+
+ def save(self):
+ if self.quit_dialog.value:
+ self.quit_dialog.close()
+
+ self.gpt_client.model = self.model_select.value
+ self.blitz_ui.preprompt = self.preprompt.value
+ self.gpt_client.refresh_client(api_key=self.api_key_input.value)
+ ui.notify("Settings saved", type="positive")
+ self.dialog.close()
+
+ async def validate_api_key(self):
+ try:
+ gpt_client = GPTClient(api_key=self.api_key_input.value)
+ await gpt_client.list_models()
+ ui.notify("Valid API Key", type="positive")
+ except (AuthenticationError, APIConnectionError):
+ ui.notify("Invalid API Key", type="warning")
diff --git a/blitz/ui/pages/log.py b/blitz/ui/pages/log.py
new file mode 100644
index 0000000..f126227
--- /dev/null
+++ b/blitz/ui/pages/log.py
@@ -0,0 +1,10 @@
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from blitz.ui.components.logger import LogComponent
+
+
+class LogPage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+
+ def render_page(self) -> None:
+ LogComponent().render()
diff --git a/blitz/ui/pages/projects.py b/blitz/ui/pages/projects.py
new file mode 100644
index 0000000..198299d
--- /dev/null
+++ b/blitz/ui/pages/projects.py
@@ -0,0 +1,61 @@
+from nicegui import ui
+
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+
+
+class ProjectDetail:
+ def __init__(
+ self,
+ app_name: str,
+ project_name: str = "",
+ date: str = "",
+ description: str = "",
+ version: str = "",
+ ) -> None:
+ self.app_name=app_name
+ self.project_name = project_name
+ self.date = date
+ self.description = description
+ self.version = version
+
+
+ def render(self):
+ with ui.link(target=f"/projects/{self.app_name}").classes("w-full hover:bg-slate-700 rounded-sm"), ui.grid(
+ columns=20
+ ).classes("w-full my-2"):
+ ui.label(self.app_name).classes("col-span-2 pl-2")
+ ui.label(self.project_name).classes("col-span-2 pl-2")
+ ui.label(self.date).classes("col-span-4")
+ ui.label(self.description).classes("col-span-11")
+ ui.label(self.version).classes("col-span-1")
+
+
+class HomePage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui()) -> None:
+ self.blitz_ui = blitz_ui
+
+ def render_page(self):
+ with ui.element("div").classes(
+ "w-full justify-center items-center content-center p-10"
+ ):
+ with ui.card().classes("no-shadow border align-center"):
+ with ui.row().classes("w-full justify-between items-center"):
+ ui.label("Blitz Projects").classes("text-2xl")
+ with ui.button("New").props("disabled").props("flat"):
+ ui.tooltip(
+ "This feature is not developed yet. Create a new project with the CLI."
+ )
+ ui.input(label="Search for project").props(
+ "borderless standout dense"
+ ).classes(" rounded-lg px-2 border-solid border w-full my-5")
+ with ui.grid(columns=20).classes("w-full"):
+ ui.label("App").classes("col-span-2 pl-2")
+ ui.label("Name").classes("col-span-2 pl-2")
+ ui.label("Last modified").classes("col-span-4")
+ ui.label("Description").classes("col-span-11")
+ ui.label("Version").classes("col-span-1")
+
+ ui.separator()
+
+ for app in self.blitz_ui.apps:
+ ProjectDetail(app_name=app.name, project_name=app.file.config.name, description=app.file.config.description,version=app.file.config.version).render()
diff --git a/blitz/ui/pages/swagger.py b/blitz/ui/pages/swagger.py
new file mode 100644
index 0000000..cf1900d
--- /dev/null
+++ b/blitz/ui/pages/swagger.py
@@ -0,0 +1,26 @@
+import pathlib
+from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui
+from nicegui import ui
+from pathlib import Path
+
+
+class SwaggerPage:
+ def __init__(self, blitz_ui: BlitzUI = get_blitz_ui(), project: str = None) -> None:
+ self.blitz_ui = blitz_ui
+
+ def resize_iframe(self):
+ ui.run_javascript(
+ """
+ var iframe = document.querySelector('iframe');
+ var resizeIframe = function() {
+ iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px';
+ };
+
+ """)
+
+ def render_page(self):
+ self.resize_iframe()
+
+ ui.element("iframe").props(
+ "src={self.blitz_ui.localhost_url}/api/docs frameborder=0 onload=resizeIframe()"
+ ).classes("w-full rounded-sm bg-white h-screen overflow-hidden")
diff --git a/blitz/ui/preprompt.txt b/blitz/ui/preprompt.txt
new file mode 100644
index 0000000..bf3f759
--- /dev/null
+++ b/blitz/ui/preprompt.txt
@@ -0,0 +1,49 @@
+You are an assistant to create json files. These json are used to create a blitz app which is a CRUD API.
+For exemple this is a valid blitz_file:
+{
+ "config": {
+ "name": "Game Chess",
+ "description": "Player and Game to represent a game of chess.",
+ "version": "0.1.0"
+ },
+ "models": [
+ {
+ "name": "Test",
+ "fields": {
+ "name": "str",
+ "age": "int"
+ }
+ },
+ {
+ "name": "Player",
+ "fields": {
+ "name": "str"
+ }
+ },
+ {
+ "name": "Game",
+ "fields": {
+ "player_id": "Player.id",
+ "player": "Player"
+ }
+ },
+ {
+ "name": "Resource",
+ "fields": {
+ "name": "str",
+ "game_id": "Game.id",
+ "game": "Game"
+ }
+ },
+ {
+ "name": "Item",
+ "fields": {
+ "name": "str",
+ "resource_id": "Resource.id",
+ "player_id": "Player.id"
+ }
+ }
+ ]
+}
+
+You MUST ALWAYS answer in markdown.
\ No newline at end of file
diff --git a/blitz/ui/static/jse_theme_dark.css b/blitz/ui/static/jse_theme_dark.css
new file mode 100644
index 0000000..b48bca9
--- /dev/null
+++ b/blitz/ui/static/jse_theme_dark.css
@@ -0,0 +1,115 @@
+.jse-theme-dark {
+ --jse-theme: dark;
+
+ /* over all fonts, sizes, and colors */
+ --jse-theme-color: #2f6dd0;
+ --jse-theme-color-highlight: #467cd2;
+ --jse-background-color: #1e2129;
+ --jse-text-color: #d4d4d4;
+ --jse-text-color-inverse: #4d4d4d;
+
+ /* main, menu, modal */
+ --jse-main-border: 1px solid grey;
+ --jse-menu-color: #fff;
+ --jse-modal-background: #2f2f2f;
+ --jse-modal-overlay-background: rgba(0, 0, 0, 0.5);
+ --jse-modal-code-background: #2f2f2f;
+
+ /* tooltip in text mode */
+ --jse-tooltip-color: var(--jse-text-color);
+ --jse-tooltip-background: #4b4b4b;
+ --jse-tooltip-border: 1px solid #737373;
+ --jse-tooltip-action-button-color: inherit;
+ --jse-tooltip-action-button-background: #737373;
+
+ /* panels: navigation bar, gutter, search box */
+ --jse-panel-background: #333333;
+ --jse-panel-background-border: 1px solid #464646;
+ --jse-panel-color: var(--jse-text-color);
+ --jse-panel-color-readonly: #737373;
+ --jse-panel-border: 1px solid #3c3c3c;
+ --jse-panel-button-color-highlight: #e5e5e5;
+ --jse-panel-button-background-highlight: #464646;
+
+ /* navigation-bar */
+ --jse-navigation-bar-background: #656565;
+ --jse-navigation-bar-background-highlight: #7e7e7e;
+ --jse-navigation-bar-dropdown-color: var(--jse-text-color);
+
+ /* context menu */
+ --jse-context-menu-background: #4b4b4b;
+ --jse-context-menu-background-highlight: #595959;
+ --jse-context-menu-separator-color: #595959;
+ --jse-context-menu-color: var(--jse-text-color);
+ --jse-context-menu-pointer-background: #737373;
+ --jse-context-menu-pointer-background-highlight: #818181;
+ --jse-context-menu-pointer-color: var(--jse-context-menu-color);
+
+ /* contents: json key and values */
+ --jse-key-color: #9cdcfe;
+ --jse-value-color: var(--jse-text-color);
+ --jse-value-color-number: #b5cea8;
+ --jse-value-color-boolean: #569cd6;
+ --jse-value-color-null: #569cd6;
+ --jse-value-color-string: #ce9178;
+ --jse-value-color-url: #ce9178;
+ --jse-delimiter-color: #949494;
+ --jse-edit-outline: 2px solid var(--jse-text-color);
+
+ /* contents: selected or hovered */
+ --jse-selection-background-color: #464646;
+ --jse-selection-background-inactive-color: #333333;
+ --jse-hover-background-color: #343434;
+ --jse-active-line-background-color: rgba(255, 255, 255, 0.06);
+ --jse-search-match-background-color: #343434;
+
+ /* contents: section of collapsed items in an array */
+ --jse-collapsed-items-background-color: #333333;
+ --jse-collapsed-items-selected-background-color: #565656;
+ --jse-collapsed-items-link-color: #b2b2b2;
+ --jse-collapsed-items-link-color-highlight: #ec8477;
+
+ /* contents: highlighting of search results */
+ --jse-search-match-color: #724c27;
+ --jse-search-match-outline: 1px solid #966535;
+ --jse-search-match-active-color: #9f6c39;
+ --jse-search-match-active-outline: 1px solid #bb7f43;
+
+ /* contents: inline tags inside the JSON document */
+ --jse-tag-background: #444444;
+ --jse-tag-color: #bdbdbd;
+
+ /* contents: table */
+ --jse-table-header-background: #333333;
+ --jse-table-header-background-highlight: #424242;
+ --jse-table-row-odd-background: rgba(255, 255, 255, 0.1);
+
+ /* controls in modals: inputs, buttons, and `a` */
+ --jse-input-background: #3d3d3d;
+ --jse-input-border: var(--jse-main-border);
+ --jse-button-background: #808080;
+ --jse-button-background-highlight: #7a7a7a;
+ --jse-button-color: #e0e0e0;
+ --jse-button-secondary-background: #494949;
+ --jse-button-secondary-background-highlight: #5d5d5d;
+ --jse-button-secondary-background-disabled: #9d9d9d;
+ --jse-button-secondary-color: var(--jse-text-color);
+ --jse-a-color: #55abff;
+ --jse-a-color-highlight: #4387c9;
+
+ /* svelte-select */
+ --jse-svelte-select-background: #3d3d3d;
+ --jse-svelte-select-border: 1px solid #4f4f4f;
+ --list-background: #3d3d3d;
+ --item-hover-bg: #505050;
+ --multi-item-bg: #5b5b5b;
+ --input-color: #d4d4d4;
+ --multi-clear-bg: #8a8a8a;
+ --multi-item-clear-icon-color: #d4d4d4;
+ --multi-item-outline: 1px solid #696969;
+ --list-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
+
+ /* color picker */
+ --jse-color-picker-background: #656565;
+ --jse-color-picker-border-box-shadow: #8c8c8c 0 0 0 1px;
+ }
\ No newline at end of file
diff --git a/blitz/ui/static/style.css b/blitz/ui/static/style.css
new file mode 100644
index 0000000..bcc10b7
--- /dev/null
+++ b/blitz/ui/static/style.css
@@ -0,0 +1,29 @@
+html,
+body {
+ max-width: 100%;
+ overflow-x: hidden;
+ background-color: #fefefe;
+ font-family: "Fira Sans", Roboto, -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
+ --q-dark-page: #1e2129;
+ }
+
+html:has(.body--dark) {
+ background-color: #1e2129;
+}
+
+.body--dark .q-header {
+ background-color: #14151a;
+ }
+
+a:link:not(.browser-window *),
+a:visited:not(.browser-window *) {
+ color: inherit !important;
+ text-decoration: none;
+}
+
+.q-card {
+ background-color: #f8f8f8;
+}
+.q-card--dark {
+ background-color: #1e2129;
+}
\ No newline at end of file
diff --git a/custom_templates/pydantic_v2/BaseModel.jinja2 b/custom_templates/pydantic_v2/BaseModel.jinja2
new file mode 100644
index 0000000..bf7357d
--- /dev/null
+++ b/custom_templates/pydantic_v2/BaseModel.jinja2
@@ -0,0 +1,39 @@
+{% for decorator in decorators -%}
+{{ decorator }}
+{% endfor -%}
+class {{ class_name }}({{ base_class }}, table=True):{% if comment is defined %} # {{ comment }}{% endif %}
+{%- if description %}
+ """
+ {{ description | indent(4) }}
+ """
+{%- endif %}
+{%- if not fields and not description %}
+ pass
+{%- endif %}
+{%- if config %}
+{%- filter indent(4) %}
+{% include 'ConfigDict.jinja2' %}
+{%- endfilter %}
+{%- endif %}
+{%- for field in fields -%}
+ {%- if not field.annotated and field.field %}
+ {{ field.name }}: {{ field.type_hint }} = {{ field.field }}
+ {%- else %}
+ {%- if field.annotated %}
+ {{ field.name }}: {{ field.annotated }}
+ {%- else %}
+ {{ field.name }}: {{ field.type_hint }}
+ {%- endif %}
+ {%- if (not field.required or field.data_type.is_optional or field.nullable) and not field.annotated
+ %} = {{ field.represented_default }}
+ {%- endif -%}
+ {%- endif %}
+ {%- if field.docstring %}
+ """
+ {{ field.docstring | indent(4) }}
+ """
+ {%- endif %}
+{%- for method in methods -%}
+ {{ method }}
+{%- endfor -%}
+{%- endfor -%}
\ No newline at end of file
diff --git a/custom_templates/pydantic_v2/RootModel.jinja2 b/custom_templates/pydantic_v2/RootModel.jinja2
new file mode 100644
index 0000000..5b5a9f2
--- /dev/null
+++ b/custom_templates/pydantic_v2/RootModel.jinja2
@@ -0,0 +1,44 @@
+{%- macro get_type_hint(_fields) -%}
+{%- if _fields -%}
+{#There will only ever be a single field for RootModel#}
+{{- _fields[0].type_hint}}
+{%- endif -%}
+{%- endmacro -%}
+
+{% for decorator in decorators -%}
+{{ decorator }}
+{% endfor -%}
+
+class {{ class_name }}({{ base_class }}[{{get_type_hint(fields)}}]):{% if comment is defined %} # {{ comment }}{% endif %}
+{%- if description %}
+ """
+ {{ description | indent(4) }}
+ """
+{%- endif %}
+{%- if config %}
+{%- filter indent(4) %}
+{% include 'ConfigDict.jinja2' %}
+{%- endfilter %}
+{%- endif %}
+{%- if not fields and not description %}
+ pass
+{%- else %}
+ {%- set field = fields[0] %}
+ {%- if not field.annotated and field.field %}
+ root: {{ field.type_hint }} = {{ field.field }}
+ {%- else %}
+ {%- if field.annotated %}
+ root: {{ field.annotated }}
+ {%- else %}
+ root: {{ field.type_hint }}
+ {%- endif %}
+ {%- if not (field.required or (field.represented_default == 'None' and field.strip_default_none))
+ %} = {{ field.represented_default }}
+ {%- endif -%}
+ {%- endif %}
+ {%- if field.docstring %}
+ """
+ {{ field.docstring | indent(4) }}
+ """
+ {%- endif %}
+{%- endif %}
\ No newline at end of file
diff --git a/custom_templates/root.jinja2 b/custom_templates/root.jinja2
new file mode 100644
index 0000000..847a823
--- /dev/null
+++ b/custom_templates/root.jinja2
@@ -0,0 +1,6 @@
+{%- set field = fields[0] %}
+{%- if field.annotated %}
+{{ class_name }} = {{ field.annotated }}
+{%- else %}
+{{ class_name }} = {{ field.type_hint }}
+{%- endif %}
diff --git a/docs/api/index.md b/docs/api/index.md
new file mode 100644
index 0000000..a1e865a
--- /dev/null
+++ b/docs/api/index.md
@@ -0,0 +1,2 @@
+!!! warning
+ **WORK IN PROGRESS**
\ No newline at end of file
diff --git a/docs/blitzfile/config.md b/docs/blitzfile/config.md
new file mode 100644
index 0000000..5a4e39d
--- /dev/null
+++ b/docs/blitzfile/config.md
@@ -0,0 +1,32 @@
+
+The config section contains the general informations about your Blitz app. It is built as below:
+
+=== "Yaml"
+
+ ```yaml
+ config:
+ name: Hello world # (1)!
+ description: Here is a simple blitz configuration file. # (2)!
+ version: 0.1.0 # (3)!
+ ```
+
+ 1. The name of your Blitz app.
+ 2. A short description of your Blitz app.
+ 3. The version of your Blitz app.
+
+
+=== "Json"
+
+ ```json
+ "config": {
+ "name": "Hello world", // (1)!
+ "description": "Here is a simple blitz configuration file.", // (2)!
+ "version": "0.1.0" // (3)!
+ }
+ ```
+
+ 1. The name of your Blitz app.
+ 2. A short description of your Blitz app.
+ 3. The version of your Blitz app.
+
+
diff --git a/docs/blitzfile/index.md b/docs/blitzfile/index.md
new file mode 100644
index 0000000..7457883
--- /dev/null
+++ b/docs/blitzfile/index.md
@@ -0,0 +1,181 @@
+
+!!! warning
+
+ **Please, keep in mind that the Blitz is still in development and may change in the future. We are open to suggestions and contributions.**
+
+The Blitz file is the configuration file used by Blitz to generate the API. It is a simple YAML or JSON file that contains the general configuration of the API and the database models.
+
+> We are building the Blitz File in a way that it is easy to read and understand with every feature needed to build a complete basic API.
+
+## Blitz File Structure
+
+The Blitz file is composed of two main sections: [`config`](#config) and [`resources`](#resources).
+
+### Config
+
+The config section contains the general configuration of the API. It is built as below:
+
+=== "Yaml"
+
+ ```yaml
+ config:
+ name: Hello world
+ description: Here is a simple blitz configuration file.
+ version: 0.1.0
+ ```
+
+=== "Json"
+
+ ```json
+ "config": {
+ "name": "Hello world",
+ "description": "Here is a simple blitz configuration file.",
+ "version": "0.1.0"
+ }
+ ```
+
+> *Pretty easy right ?*
+
+### Resources
+
+The `resources` section contains the structure of the data you want to manipulate in your Blitz app. It's a bit more complex than the config section but still easy to understand.
+
+!!! note
+
+ We are not talking about `database` or `models` here because Blitz is an abstraction of your data model and things that are represented in your Blitz file as a `resource` may be represented differently in your database.
+
+The resources section is built as below:
+
+=== "Yaml"
+
+ ```yaml
+ models:
+ - name: TodoList
+ fields:
+ - name: Todo
+ fields:
+ ```
+
+=== "Json"
+
+ ```json
+ "models": [
+ {
+ "name": "TodoList",
+ "fields": {}
+ },
+ {
+ "name": "Todo",
+ "fields": {}
+ }
+ ]
+ ```
+
+Each model is constructed with a `name` and a `fields` section. The `name` is the name of the model and the `fields` section contains the fields of the model.
+
+> *Still pretty easy right ?*
+
+___
+
+#### Fields
+
+A field can be constructed in 2 way, the **explicit** way and the **shortcut** way.
+
+You can use both way in the same Blitz file because as the name says, the shortcut way is just a shortcut to the explicit way.
+
+Here is an example of a working Blitz file:
+
+=== "Yaml"
+
+ ```yaml
+ models:
+ - name: TodoList
+ fields:
+ owner!: str
+ description: str
+ - name: Todo
+ fields:
+ due_date: str
+ todo_list_id: TodoList.id
+ ```
+
+=== "Json"
+
+ ```json
+ "models": [
+ {
+ "name": "TodoList",
+ "fields": {
+ "owner!": "str",
+ "description": "str"
+ }
+ },
+ {
+ "name": "Todo",
+ "fields": {
+ "due_date": "str",
+ "todo_list_id": "TodoList.id"
+ }
+ }
+ ]
+ ```
+
+=== "Yaml (explicit)"
+
+ ```yaml
+ models:
+ - name: TodoList
+ fields:
+ owner:
+ type: str
+ unique: true
+ description:
+ type: str
+ - name: Todo
+ fields:
+ due_date:
+ type: str
+ todo_list_id:
+ type: uuid
+ relationship: TodoList.id
+ ```
+
+=== "Json (explicit)"
+
+ ```json
+ "models": [
+ {
+ "name": "TodoList",
+ "fields": {
+ "owner": {
+ "type": "str",
+ "unique": true
+ },
+ "description": {
+ "type": "str"
+ }
+ }
+ },
+ {
+ "name": "Todo",
+ "fields": {
+ "due_date": {
+ "type": "str"
+ },
+ "todo_list_id": {
+ "type": "uuid",
+ "relationship": "TodoList.id"
+ }
+ }
+ }
+ ]
+ ```
+
+
+> *You can try it in the [Blitz Playground](#)* !
+
+!!! note
+
+ We will maintain the 4 ways of writing fields in the Blitz file because we think that the explicit way is more readable and the shortcut way is more convenient. We are also about to implement in the Blitz dashboard a way to switch between the 4 ways really easily.
+
+Every field is constructed at least with a `name` and a `type`.
\ No newline at end of file
diff --git a/docs/blitzfile/reference.md b/docs/blitzfile/reference.md
new file mode 100644
index 0000000..a1e865a
--- /dev/null
+++ b/docs/blitzfile/reference.md
@@ -0,0 +1,2 @@
+!!! warning
+ **WORK IN PROGRESS**
\ No newline at end of file
diff --git a/docs/blitzfile/relationship.md b/docs/blitzfile/relationship.md
new file mode 100644
index 0000000..96c0677
--- /dev/null
+++ b/docs/blitzfile/relationship.md
@@ -0,0 +1,249 @@
+## Relationship
+Currently supported relationships are:
+- One-to-many
+- One-to-one
+
+### One-to-Many
+> A **Player** has many **Item**s.
+
+In the following example, a **player has many items**
+=== "Yaml"
+ ```yaml
+ - name: Player
+ fields:
+ name: str
+ items: Item[]
+ - name: Item
+ fields:
+ name: str
+ player_id: Player.id
+ player: Player
+ ```
+
+=== "Json"
+ ```json
+ [
+ {
+ "name": "Player",
+ "fields": {
+ "name": "str",
+ "items": "Item[]"
+ }
+ },
+ {
+ "name": "Item",
+ "fields": {
+ "name": "str",
+ "player_id": "Player.id",
+ "player": "Player"
+ }
+ }
+ ]
+ ```
+
+=== "Yaml (explicit)"
+ ```yaml
+ - name: Player
+ fields:
+ name:
+ type: str
+ items:
+ type: relationship
+ relationship: Item
+ relationship_list: true
+ - name: Item
+ fields:
+ name:
+ type: str
+ player_id:
+ type: foreign_key
+ foreign_key: Player.id
+ player:
+ type: relationship
+ relationship: Player
+ ```
+
+=== "Json (explicit)"
+ ```json
+ [
+ {
+ "name": "Player",
+ "fields": {
+ "name": {
+ "type": "str"
+ },
+ "items": {
+ "type": "relationship",
+ "relationship": "Item",
+ "relationship_list": true
+ }
+ }
+ },
+ {
+ "name": "Item",
+ "fields": {
+ "name": {
+ "type": "str"
+ },
+ "player_id": {
+ "type": "foreign_key",
+ "foreign_key": "Player.id"
+
+ },
+ "player": {
+ "type": "relationship",
+ "relationship": "Player"
+ }
+ }
+ }
+ ]
+ ```
+
+
+By specifying the player relationship from the **Item** entity, we made a `Item->Player` relationship where an **Item** is related to a single **Player**.
+
+Because the **Player** entity don't have any relationship declared, there is no rules concerning the relationship between `Player->Item`.
+
+!!! note
+ As you can see, you can declare a `items` relationship in the `Player` resource to make the relationship usable from the `Player` entity.
+
+ This is fully optional and it don't do anything about the real relationship between `Player` and `Item` because evrything is set in the `Item` resource, but it allow the `Player` resource to display the linked `Item`s resources.
+
+
+Then, one **Item** belongs to one **Player** entity and one **Player** can have multiple **Item** entities. This is a **One to Many** relationship.
+
+
+### One to One
+
+In the following example, a **player has one bank account** and a **bank has many accounts**.
+=== "Yaml"
+ ```yaml
+ - name: Player
+ fields:
+ name: str
+ account: BankAccount
+ - name: Bank
+ fields:
+ name: str
+ - name: BankAccount
+ fields:
+ bank_id: Bank.id
+ bank: Bank
+ player_id: Player.id
+ player: Player
+ ```
+
+=== "Json"
+ ```json
+ [
+ {
+ "name": "Player",
+ "fields": {
+ "name": "str",
+ "account": "BankAccount"
+ }
+ },
+ {
+ "name": "Bank",
+ "fields": {
+ "name": "str"
+ }
+ },
+ {
+ "name": "BankAccount",
+ "fields": {
+ "bank_id": "Bank.id",
+ "bank": "Bank",
+ "player_id": "Player.id",
+ "player": "Player"
+ }
+ }
+ ]
+ ```
+
+=== "Yaml (explicit)"
+ ```yaml
+ - name: Player
+ fields:
+ name:
+ type: str
+ account:
+ type: relationship
+ relationship: BankAccount
+ - name: Bank
+ fields:
+ name: str
+ - name: BankAccount
+ fields:
+ bank_id:
+ type: foreign_key
+ foreign_key: Bank.id
+ bank:
+ type: relationship
+ relationship: Bank
+ player_id:
+ type: foreign_key
+ foreign_key: Player.id
+ unique: true
+ player:
+ type: relationship
+ relationship: Player
+ ```
+
+=== "Json (explicit)"
+ ```json
+ [
+ {
+ "name": "Player",
+ "fields": {
+ "name": {
+ "type": "str"
+ },
+ "account": {
+ "type": "relationship",
+ "relationship": "BankAccount"
+ }
+ }
+ },
+ {
+ "name": "Bank",
+ "fields": {
+ "name": "str"
+ }
+ },
+ {
+ "name": "BankAccount",
+ "fields": {
+ "bank_id": {
+ "type": "foreign_key",
+ "foreign_key": "Bank.id"
+ },
+ "bank": {
+ "type": "relationship",
+ "relationship": "Bank"
+ },
+ "player_id": {
+ "type": "foreign_key",
+ "foreign_key": "Player.id",
+ "unique": true
+ },
+ "player": {
+ "type": "relationship",
+ "relationship": "Player"
+ }
+ }
+ }
+ ]
+ ```
+
+By specifying the player relationship from the **BankAccount** entity, we made a `BankAccount->Player` relationship where a BankAccount is related to a single **Player**.
+
+Because we also specify the **player_id** to be unique, The relationship to a player id can only exists once. Then a **Player** can have only one **BankAccount**.
+
+
+!!! note
+ As you can see, you can declare a `account` relationship in the `Player` resource to make the relationship usable from the `Player` entity.
+
+ This is fully optional and it don't do anything about the real relationship between `Player` and `BankAccount` because evrything is set in the `BankAccount` resource, but it allow the `Player` resource to display the linked `BankAccount` resource.
+
+Then, one **Player** has one **BankAccount** entity and one **BankAccount** is related to a single **Player**. This is a **One to One** relationship.
\ No newline at end of file
diff --git a/docs/blitzfile/resources.md b/docs/blitzfile/resources.md
new file mode 100644
index 0000000..5e54706
--- /dev/null
+++ b/docs/blitzfile/resources.md
@@ -0,0 +1,310 @@
+#
+
+## Resources
+
+The `resources` section contains your Blitz resources description. It is built as below:
+
+=== "Yaml"
+
+ ```yaml
+ resources:
+ - name: Book
+ fields:
+ ```
+
+=== "Json"
+
+ ```json
+ "resources": [
+ {
+ "name": "Book",
+ "fields": {}
+ }
+ ]
+ ```
+
+Each resource contains at least a `name` and a `fields` section. The `name` is the name of the resource and the `fields` section contains the fields of the resource.
+
+## Fields
+
+!!! note
+ The field section can be constructed in 2 way, the **explicit** way and the **shortcut** way. You can use both way in the same Blitz file because as the name says, the shortcut way is just a shortcut to the explicit way.
+
+Each field must contain at least a `name` and a `type`.
+
+The available field types are listed below:
+
+| Type | Description | Example |
+| ---- | ----------- | ------- |
+| `str` | A string | `Hello world` |
+| `int` | An integer | `42` |
+| `float` | A float | `3.14` |
+| `bool` | A boolean | `true` |
+| `uuid` | A UUID | `123e4567-e89b-12d3-a456-42661417` |
+| `datetime` | A datetime | `2021-01-01T00:00:00` |
+
+=== "Yaml"
+ ```yaml
+ fields:
+ description: str
+ ```
+
+=== "Json"
+ ```json
+ "fields": {
+ "description": "str"
+ }
+ ```
+
+=== "Yaml (explicit)"
+ ```yaml
+ fields:
+ description:
+ type: str
+ ```
+
+=== "Json (explicit)"
+ ```json
+ "fields": {
+ "description": {
+ "type": "str"
+ }
+ }
+ ```
+
+!!! note
+ In this example, the name of the field is `description` and the type is `str`.
+
+Let's have a look with a complete resource and then break it down:
+
+=== "Yaml"
+ ```yaml
+ resources:
+ - name: Book
+ fields:
+ title: str! # (1)!
+ identifier!: uuid! # (2)!
+ author: str? # (3)!
+ description: # (4)!
+ type: str
+ default: "Here is a description"
+ ```
+
+ 1. The field `title` is **required** because of the `!` modifier at the end of the field type (`str!`). See the [required field](#required-field) for more details.
+ 2. The field `ìdentifier` is **unique** because of the `!` modifier at the end of the field name (`identifier!`). See the [unique field](#unique-field) for more details.
+ The field `identifier` is **required** because of the `!` modifier at the end of the field type (`uuid!`). See the [required field](#required-field) for more details.
+ 3. The field `author` is **nullable** because of the `?` modifier at the end of the field type (`str?`). See the [nullable field](#nullable-field) for more details.
+ 4. The field `description` has a **default value** because of the `default` property (`default: "Here is a description"`). See the [default value](#default-value) for more details.
+
+=== "Json"
+ ```json
+ "resources": [
+ {
+ "name": "Book",
+ "fields": {
+ "title": "str!", // (1)!
+ "identifier!": "uuid!", // (2)!
+ "author": "str?", // (3)!
+ "description": { // (4)!
+ "type": "str",
+ "default": "Here is a description"
+ }
+ }
+ }
+ ]
+ ```
+
+ 1. The field `title` is **required** because of the `!` modifier at the end of the field type (`str!`). See the [required field](#required-field) for more details.
+ 2. The field `ìdentifier` is **unique** because of the `!` modifier at the end of the field name (`identifier!`). See the [unique field](#unique-field) for more details.
+ The field `identifier` is **required** because of the `!` modifier at the end of the field type (`uuid!`). See the [required field](#required-field) for more details.
+ 3. The field `author` is **nullable** because of the `?` modifier at the end of the field type (`str?`). See the [nullable field](#nullable-field) for more details.
+ 4. The field `description` has a **default value** because of the `default` property (`"default": "Here is a description"`). See the [default value](#default-value) for more details.
+
+=== "Yaml (explicit)"
+ ```yaml
+ resources:
+ - name: Book
+ fields:
+ title: # (1)!
+ type: str
+ required: true
+ identifier: # (2)!
+ type: uuid
+ unique: true
+ author: # (3)!
+ type: str
+ nullable: true
+ description: # (4)!
+ type: str
+ default: "Here is a description"
+ ```
+
+ 1. The field `title` is **required** because of the `required` propertry (`required: true`). See the [required field](#required-field) for more details.
+ 2. The field `ìdentifier` is **unique** because of the `unique` property at the end of the field name (`unique: true`). See the [unique field](#unique-field) for more details.
+ The field `identifier` is **required** because of the `required` property (`required: true`). See the [required field](#required-field) for more details.
+ 3. The field `author` is **nullable** because of the `nullable` property (`nullable: true`). See the [nullable field](#nullable-field) for more details.
+ 4. The field `description` has a **default value** because of the `default` property (`default: "Here is a description"`). See the [default value](#default-value) for more details.
+
+
+=== "Json (explicit)"
+ ```json
+ "resources": [
+ {
+ "name": "Book",
+ "fields": {
+ "title": { // (1)!
+ "type": "str",
+ "required": true
+ },
+ "identifier": { // (2)!
+ "type": "uuid",
+ "unique": true
+ },
+ "author": { // (3)!
+ "type": "str",
+ "nullable": true
+ },
+ "description": { // (4)!
+ "type": "str",
+ "default": "Here is a description"
+ }
+ }
+ }
+ ]
+ ```
+
+ 1. The field `title` is **required** because of the `required` propertry (`"required": true`). See the [required field](#required-field) for more details.
+ 2. The field `ìdentifier` is **unique** because of the `unique` property at the end of the field name (`"unique": true`). See the [unique field](#unique-field) for more details.
+ The field `identifier` is **required** because of the `required` property (`"required": true`). See the [required field](#required-field) for more details.
+ 3. The field `author` is **nullable** because of the `nullable` property (`"nullable": true`). See the [nullable field](#nullable-field) for more details.
+ 4. The field `description` has a **default value** because of the `default` property (`"default": "Here is a description"`). See the [default value](#default-value) for more details.
+
+
+
+
+
+### Unique field
+
+You can specify if a field is **unique** by adding a `!` at the end of the field name or by setting the `unique` property to `true`.
+
+=== "Yaml"
+ ```yaml
+ fields:
+ identifier!: uuid
+ ```
+
+=== "Json"
+ ```json
+ "fields": {
+ "identifier!": "uuid"
+ }
+ ```
+
+=== "Yaml (explicit)"
+ ```yaml
+ fields:
+ identifier:
+ type: uuid
+ unique: true
+ ```
+
+=== "Json (explicit)"
+ ```json
+ "fields": {
+ "identifier": {
+ "type": "uuid",
+ "unique": true
+ }
+ }
+ ```
+
+### Nullable field
+
+You can specify if a field is **nullable** by adding a `?` at the end of the field type or by setting the `nullable` property to `true`.
+
+=== "Yaml"
+ ```yaml
+ fields:
+ author: str?
+ ```
+=== "Json"
+ ```json
+ "fields": {
+ "author": "str?"
+ }
+ ```
+=== "Yaml (explicit)"
+ ```yaml
+ fields:
+ author:
+ type: str
+ nullable: true
+ ```
+=== "Json (explicit)"
+ ```json
+ "fields": {
+ "author": {
+ "type": "str",
+ "nullable": true
+ }
+ }
+ ```
+
+The default value will be set to `null` if the field is nullable. If you want to specify another default value, you can use the [`default`](#default) property.
+
+### Required field
+
+You can specify if a field is **required** by adding a `!` at the end of the field type or by setting the `required` property to `true`.
+
+=== "Yaml"
+ ```yaml
+ fields:
+ title: str!
+ ```
+=== "Json"
+ ```json
+ "fields": {
+ "title": "str!"
+ }
+ ```
+=== "Yaml (explicit)"
+ ```yaml
+ fields:
+ title:
+ type: str
+ required: true
+ ```
+=== "Json (explicit)"
+ ```json
+ "fields": {
+ "title": {
+ "type": "str",
+ "required": true
+ }
+ }
+ ```
+
+### Default value
+
+??? example "No shortcut yet"
+ There is no shortcut yet for the `default`property.
+
+You can specify a **default value** for a field by setting the `default` property to the value you want.
+
+=== "Yaml"
+ ```yaml
+ fields:
+ description:
+ type: str
+ default: "Here is a description"
+ ```
+
+=== "Json"
+ ```json
+ "fields": {
+ "description": {
+ "type": "str",
+ "default": "Here is a description"
+ }
+ }
+ ```
\ No newline at end of file
diff --git a/docs/cli/create.md b/docs/cli/create.md
new file mode 100644
index 0000000..a663341
--- /dev/null
+++ b/docs/cli/create.md
@@ -0,0 +1,25 @@
+The `blitz create` command is used to create a new blitz app. It will ask you for the name of your app, the description of your app and the format of the blitz file.
+
+The default format is `yaml`. You can also use `json` format.
+
+
+
+
+
+
+```console
+$ blitz create your-blitz-app
+Enter the description of your blitz app ():
+// this is my first blitz app
+Choose the format of the blitz file [json/yaml] (yaml):
+// yaml
+
+your-blitz-app created successfully !
+To start your app, you can use:
+ blitz start tutu
+```
+
+
+
+!!! note
+ Use `blitz create --help` to see all available options.
diff --git a/docs/cli/index.md b/docs/cli/index.md
new file mode 100644
index 0000000..22690b6
--- /dev/null
+++ b/docs/cli/index.md
@@ -0,0 +1,11 @@
+# Installation
+
+## Using [pipx](https://pipx.pypa.io/stable/installation/) (recommanded)
+```bash
+pipx install git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
+
+## Using pip
+```bash
+pip install --user git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
\ No newline at end of file
diff --git a/docs/cli/list.md b/docs/cli/list.md
new file mode 100644
index 0000000..e322203
--- /dev/null
+++ b/docs/cli/list.md
@@ -0,0 +1,18 @@
+
+
+
+
+```console
+$ blitz list
+┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
+┃ Blitz app name ┃ Version ┃
+┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
+│ another-blitz-app │ 0.0.0 │
+│ random-blitz-app │ 0.1.0 │
+└───────────────────┴─────────┘
+```
+
+
+
+!!! note
+ Use `blitz list --help` to see all available options.
diff --git a/docs/cli/references.md b/docs/cli/references.md
new file mode 100644
index 0000000..09f228f
--- /dev/null
+++ b/docs/cli/references.md
@@ -0,0 +1,3 @@
+::: mkdocs-typer
+ :module: blitz.cli.app
+ :command: app
diff --git a/docs/cli/release.md b/docs/cli/release.md
new file mode 100644
index 0000000..5eba8c2
--- /dev/null
+++ b/docs/cli/release.md
@@ -0,0 +1,15 @@
+
+
+
+
+```console
+$ blitz release random-blitz-app minor
+Blitz app random-blitz-app released at version 0.1.0
+You can now start your versioned blitz app by running:
+ blitz start random-blitz-app --version 0.1.0
+```
+
+
+
+!!! note
+ Use `blitz release --help` to see all available options.
diff --git a/docs/cli/start.md b/docs/cli/start.md
new file mode 100644
index 0000000..f11355f
--- /dev/null
+++ b/docs/cli/start.md
@@ -0,0 +1,26 @@
+The `blitz start` command is used to start an existing blitz app. It will start the blitz API, the blitz admin and the blitz UI.
+
+
+
+
+
+```console
+$ blitz start your-blitz-app
+
+This is still an alpha. Please do not use in production.
+Please report any issues on https://github.com/Paperz-org/blitz
+
+Blitz app deployed.
+ - Blitz UI : http://localhost:8100
+ - Blitz admin : http://localhost:8100/admin
+ - Swagger UI : http://localhost:8100/api/docs
+
+INFO random-blitz-app Started server process [21292026]
+INFO random-blitz-app Waiting for application startup.
+INFO random-blitz-app Application startup complete.
+```
+
+
+
+!!! note
+ Use `blitz create --help` to see all available options.
diff --git a/docs/css/colors.css b/docs/css/colors.css
new file mode 100644
index 0000000..e69de29
diff --git a/docs/css/custom.css b/docs/css/custom.css
new file mode 100644
index 0000000..bdeaae8
--- /dev/null
+++ b/docs/css/custom.css
@@ -0,0 +1,178 @@
+.termynal-comment {
+ color: #4a968f;
+ font-style: italic;
+ display: block;
+}
+
+.termy {
+ /* For right to left languages */
+ direction: ltr;
+}
+
+.termy [data-termynal] {
+ white-space: pre-wrap;
+}
+
+a.external-link {
+ /* For right to left languages */
+ direction: ltr;
+ display: inline-block;
+}
+
+a.external-link::after {
+ /* \00A0 is a non-breaking space
+ to make the mark be on the same line as the link
+ */
+ content: "\00A0[↪]";
+}
+
+a.internal-link::after {
+ /* \00A0 is a non-breaking space
+ to make the mark be on the same line as the link
+ */
+ content: "\00A0↪";
+}
+
+.shadow {
+ box-shadow: 5px 5px 10px #999;
+}
+
+/* Give space to lower icons so Gitter chat doesn't get on top of them */
+.md-footer-meta {
+ padding-bottom: 2em;
+}
+
+.user-list {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 2rem;
+}
+
+.user-list-center {
+ justify-content: space-evenly;
+}
+
+.user {
+ margin: 1em;
+ min-width: 7em;
+}
+
+.user .avatar-wrapper {
+ width: 80px;
+ height: 80px;
+ margin: 10px auto;
+ overflow: hidden;
+ border-radius: 50%;
+ position: relative;
+}
+
+.user .avatar-wrapper img {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.user .title {
+ text-align: center;
+}
+
+.user .count {
+ font-size: 80%;
+ text-align: center;
+}
+
+a.announce-link:link,
+a.announce-link:visited {
+ color: #fff;
+}
+
+a.announce-link:hover {
+ color: var(--md-accent-fg-color);
+}
+
+.announce-wrapper {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.announce-wrapper div.item {
+ display: none;
+}
+
+.announce-wrapper .sponsor-badge {
+ display: block;
+ position: absolute;
+ top: -10px;
+ right: 0;
+ font-size: 0.5rem;
+ color: #999;
+ background-color: #666;
+ border-radius: 10px;
+ padding: 0 10px;
+ z-index: 10;
+}
+
+.announce-wrapper .sponsor-image {
+ display: block;
+ border-radius: 20px;
+}
+
+.announce-wrapper > div {
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+}
+
+.twitter {
+ color: #00acee;
+}
+
+/* Right to left languages */
+code {
+ direction: ltr;
+ display: inline-block;
+}
+
+.illustration {
+ margin-top: 2em;
+ margin-bottom: 2em;
+}
+
+/* Screenshots */
+/*
+Simulate a browser window frame.
+Inspired by Termynal's CSS tricks with modifications
+*/
+
+.screenshot {
+ display: block;
+ background-color: #d3e0de;
+ border-radius: 4px;
+ padding: 45px 5px 5px;
+ position: relative;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+.screenshot img {
+ display: block;
+ border-radius: 2px;
+}
+
+.screenshot:before {
+ content: "";
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ /* A little hack to display the window buttons in one pseudo element. */
+ background: #d9515d;
+ -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+ box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+}
diff --git a/docs/css/termynal.css b/docs/css/termynal.css
new file mode 100644
index 0000000..332af87
--- /dev/null
+++ b/docs/css/termynal.css
@@ -0,0 +1,109 @@
+/**
+ * termynal.js
+ *
+ * @author Ines Montani
+ * @version 0.0.1
+ * @license MIT
+ */
+
+:root {
+ --color-bg: #252a33;
+ --color-text: #eee;
+ --color-text-subtle: #a2a2a2;
+}
+
+[data-termynal] {
+ width: 750px;
+ max-width: 100%;
+ background: var(--color-bg);
+ color: var(--color-text);
+ /* font-size: 18px; */
+ font-size: 15px;
+ /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */
+ font-family: "Roboto Mono", "Fira Mono", Consolas, Menlo, Monaco,
+ "Courier New", Courier, monospace;
+ border-radius: 4px;
+ padding: 75px 45px 35px;
+ position: relative;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+[data-termynal]:before {
+ content: "";
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ display: inline-block;
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ /* A little hack to display the window buttons in one pseudo element. */
+ background: #d9515d;
+ -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+ box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930;
+}
+
+[data-termynal]:after {
+ content: "bash";
+ position: absolute;
+ color: var(--color-text-subtle);
+ top: 5px;
+ left: 0;
+ width: 100%;
+ text-align: center;
+}
+
+a[data-terminal-control] {
+ text-align: right;
+ display: block;
+ color: #aebbff;
+}
+
+[data-ty] {
+ display: block;
+ line-height: 2;
+}
+
+[data-ty]:before {
+ /* Set up defaults and ensure empty lines are displayed. */
+ content: "";
+ display: inline-block;
+ vertical-align: middle;
+}
+
+[data-ty="input"]:before,
+[data-ty-prompt]:before {
+ margin-right: 0.75em;
+ color: var(--color-text-subtle);
+}
+
+[data-ty="input"]:before {
+ content: "$";
+}
+
+[data-ty][data-ty-prompt]:before {
+ content: attr(data-ty-prompt);
+}
+
+[data-ty-cursor]:after {
+ content: attr(data-ty-cursor);
+ font-family: monospace;
+ margin-left: 0.5em;
+ -webkit-animation: blink 1s infinite;
+ animation: blink 1s infinite;
+}
+
+/* Cursor animation */
+
+@-webkit-keyframes blink {
+ 50% {
+ opacity: 0;
+ }
+}
+
+@keyframes blink {
+ 50% {
+ opacity: 0;
+ }
+}
diff --git a/docs/dashboard.md b/docs/dashboard.md
new file mode 100644
index 0000000..a1e865a
--- /dev/null
+++ b/docs/dashboard.md
@@ -0,0 +1,2 @@
+!!! warning
+ **WORK IN PROGRESS**
\ No newline at end of file
diff --git a/docs/features.md b/docs/features.md
new file mode 100644
index 0000000..4cf0f59
--- /dev/null
+++ b/docs/features.md
@@ -0,0 +1,19 @@
+#
+
+> *Powered by [FastAPI](https://fastapi.tiangolo.com/), [Pydantic](https://pydantic-docs.helpmanual.io/) and [SQLModel](https://sqlmodel.tiangolo.com/).*
+
+## CRUD API
+
+Get a CRUD API from your data description in the Blitz file.
+
+## Automatic swagger
+
+Get a swagger UI for all you routes with automatic documentation.
+
+## Database management
+
+Internally manages database schema and generates data migration to keep your database up to date with your Blitz file.
+
+## Dashboard
+
+Use the intuitive dashboard to build your Blitz app, manage your data and test your API.
\ No newline at end of file
diff --git a/docs/images/blitz_banner.png b/docs/images/blitz_banner.png
new file mode 100644
index 0000000..ba2a19f
Binary files /dev/null and b/docs/images/blitz_banner.png differ
diff --git a/docs/images/logo.png b/docs/images/logo.png
new file mode 100644
index 0000000..4635581
Binary files /dev/null and b/docs/images/logo.png differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..c1ed9cf
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,82 @@
+#
+
+![image info](./images/blitz_banner.png)
+
+ ⚡️ Lightspeed API builder ⚡️
+
+
+___
+
+
+
+# **What is Blitz ?**
+Blitz is a tool that build restfull API on the fly based on a simple and easy to maintain configuration file.
+
+Here is an example of how simple a Blitz file is:
+
+=== "Yaml"
+
+ ```yaml
+ config:
+ name: Hello world
+ description: Here is a simple blitz configuration file.
+ version: 0.1.0
+ models:
+ - name: TodoList
+ fields:
+ name: str
+ description: str
+ - name: Todo
+ fields:
+ name: str
+ due_date: str
+ todo_list_id: TodoList.id
+ ```
+
+=== "Json"
+
+ ```json
+ {
+ "config": {
+ "name": "Hello world",
+ "description": "Here is a simple blitz configuration file.",
+ "version": "0.1.0"
+ },
+ "models": [
+ {
+ "name": "TodoList",
+ "fields": {
+ "name": "str",
+ "description": "str"
+ }
+ },
+ {
+ "name": "Todo",
+ "fields": {
+ "name": "str",
+ "due_date": "str",
+ "todo_list_id": "TodoList.id"
+ }
+ }
+ ]
+ }
+ ```
+
+Just run:
+```
+blitz start your-blitz-project
+```
+*And yeah, that's it.*
+
+!!! note
+ Assuming a your-blitz-project directory created using the blitz create command
+
+___
+
+You have now a fully functional API with two models and the corresponding database schema with all the modern feature you can expect from a modern app like:
+
+- Automatic Swagger UI for the API
+- Data validation and error messages (thanks to Fastapi and SQLModel)
+- Automatic database migration based on the changes of your blitz file
+- Generated ERD diagram
+- and more...
\ No newline at end of file
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..08a3d3d
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,11 @@
+#
+
+## Using [pipx](https://pipx.pypa.io/stable/installation/) (recommanded)
+```bash
+pipx install git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
+
+## Using pip
+```bash
+pip install --user git+ssh://git@github.com/Paperz-org/blitz.git@feature/add-doc
+```
\ No newline at end of file
diff --git a/docs/js/custom.js b/docs/js/custom.js
new file mode 100644
index 0000000..4351cb3
--- /dev/null
+++ b/docs/js/custom.js
@@ -0,0 +1,189 @@
+const div = document.querySelector(".github-topic-projects");
+
+async function getDataBatch(page) {
+ const response = await fetch(
+ `https://api.github.com/search/repositories?q=topic:fastapi&per_page=100&page=${page}`,
+ { headers: { Accept: "application/vnd.github.mercy-preview+json" } }
+ );
+ const data = await response.json();
+ return data;
+}
+
+async function getData() {
+ let page = 1;
+ let data = [];
+ let dataBatch = await getDataBatch(page);
+ data = data.concat(dataBatch.items);
+ const totalCount = dataBatch.total_count;
+ while (data.length < totalCount) {
+ page += 1;
+ dataBatch = await getDataBatch(page);
+ data = data.concat(dataBatch.items);
+ }
+ return data;
+}
+
+function setupTermynal() {
+ document.querySelectorAll(".use-termynal").forEach((node) => {
+ node.style.display = "block";
+ new Termynal(node, {
+ lineDelay: 500,
+ });
+ });
+ const progressLiteralStart = "---> 100%";
+ const promptLiteralStart = "$ ";
+ const inlinePromptLiteralStart = "> ";
+ const customPromptLiteralStart = "# ";
+ const termynalActivateClass = "termy";
+ let termynals = [];
+
+ function createTermynals() {
+ document
+ .querySelectorAll(`.${termynalActivateClass} .highlight`)
+ .forEach((node) => {
+ const text = node.textContent;
+ const lines = text.split("\n");
+ const useLines = [];
+ let buffer = [];
+ function saveBuffer() {
+ if (buffer.length) {
+ let isBlankSpace = true;
+ buffer.forEach((line) => {
+ if (line) {
+ isBlankSpace = false;
+ }
+ });
+ dataValue = {};
+ if (isBlankSpace) {
+ dataValue["delay"] = 0;
+ }
+ if (buffer[buffer.length - 1] === "") {
+ // A last single won't have effect
+ // so put an additional one
+ buffer.push("");
+ }
+ const bufferValue = buffer.join(" ");
+ dataValue["value"] = bufferValue;
+ useLines.push(dataValue);
+ buffer = [];
+ }
+ }
+ for (let line of lines) {
+ if (line === progressLiteralStart) {
+ saveBuffer();
+ useLines.push({
+ type: "progress",
+ });
+ } else if (line.startsWith(promptLiteralStart)) {
+ saveBuffer();
+ const value = line.replace(promptLiteralStart, "").trimEnd();
+ useLines.push({
+ type: "input",
+ value: value,
+ });
+ } else if (line.startsWith("// ")) {
+ saveBuffer();
+ const value = "💬 " + line.replace("// ", "").trimEnd();
+ useLines.push({
+ value: value,
+ class: "termynal-comment",
+ delay: 0,
+ });
+ } else if (line.startsWith(customPromptLiteralStart)) {
+ saveBuffer();
+ const promptStart = line.indexOf(promptLiteralStart);
+ if (promptStart === -1) {
+ console.error("Custom prompt found but no end delimiter", line);
+ }
+ const prompt = line
+ .slice(0, promptStart)
+ .replace(customPromptLiteralStart, "");
+ let value = line.slice(promptStart + promptLiteralStart.length);
+ useLines.push({
+ type: "input",
+ value: value,
+ prompt: prompt,
+ });
+ } else {
+ buffer.push(line);
+ }
+ }
+ saveBuffer();
+ const div = document.createElement("div");
+ node.replaceWith(div);
+ const termynal = new Termynal(div, {
+ lineData: useLines,
+ noInit: true,
+ lineDelay: 500,
+ });
+ termynals.push(termynal);
+ });
+ }
+
+ function loadVisibleTermynals() {
+ termynals = termynals.filter((termynal) => {
+ if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) {
+ termynal.init();
+ return false;
+ }
+ return true;
+ });
+ }
+ window.addEventListener("scroll", loadVisibleTermynals);
+ createTermynals();
+ loadVisibleTermynals();
+}
+
+function shuffle(array) {
+ var currentIndex = array.length,
+ temporaryValue,
+ randomIndex;
+ while (0 !== currentIndex) {
+ randomIndex = Math.floor(Math.random() * currentIndex);
+ currentIndex -= 1;
+ temporaryValue = array[currentIndex];
+ array[currentIndex] = array[randomIndex];
+ array[randomIndex] = temporaryValue;
+ }
+ return array;
+}
+
+async function showRandomAnnouncement(groupId, timeInterval) {
+ const announceFastAPI = document.getElementById(groupId);
+ if (announceFastAPI) {
+ let children = [].slice.call(announceFastAPI.children);
+ children = shuffle(children);
+ let index = 0;
+ const announceRandom = () => {
+ children.forEach((el, i) => {
+ el.style.display = "none";
+ });
+ children[index].style.display = "block";
+ index = (index + 1) % children.length;
+ };
+ announceRandom();
+ setInterval(announceRandom, timeInterval);
+ }
+}
+
+async function main() {
+ if (div) {
+ data = await getData();
+ div.innerHTML = "