Compare commits

..

7 commits

16 changed files with 218 additions and 180 deletions

View file

@ -1,3 +1,4 @@
DEBUG=true
SESSION_KEY=debug SESSION_KEY=debug
CSRF_KEY=debug CSRF_KEY=debug
DB_HOST=localhost DB_HOST=localhost

View file

@ -1,5 +1,6 @@
dev: dev:
DEBUG="true" python3 -m uvicorn main:app --reload python3 -m dotenv -f .env_debug run \
python3 -m uvicorn main:app --reload
prod: prod:
python3 -m uvicorn main:app python3 -m uvicorn main:app
@ -16,6 +17,6 @@ docker:
clean: clean:
rm -rf app/__pycache__ rm -rf app/__pycache__
rm -rf app/paths/__pycache__ rm -rf app/*/__pycache__
rm -rf __pycache__ rm -rf __pycache__
rm -rf .mypy_cache rm -rf .mypy_cache

View file

@ -14,6 +14,38 @@ Includes Jinja, WTForms, MySQL ORM and Docker.
- Dockerfile, Docker-Compose - Dockerfile, Docker-Compose
## Usage
1. Create a repository from this template
2. For debugging, change the database connection parameters
in `.env_debug` file correspoding to the configuration
of MariaDB/MySQL server on your PC
3. Edit app [paths](#paths),
[custom error pages](#custom-error-pages)
and [forms](#wtforms)
as explained in the "Structure" section
4. Create your own Jinja templates in the `templates/` directory,
editing `base.html` and inheriting other templates from it is recommended
(see `index,table,admin.html` for examples)
5. Edit or remove CSS and JS files in `static/`
6. Edit `sql/models,schemas,crud.py`
corresponding to your database structure
as explained below ([Structure > Database](#database))
7. Check if `Makefile`, `Dockerfile`, `docker-compose.yml` are correct
8. Run the formatter and linters (`make format`, then `make check`)
9. Build a docker image and [publish](#publishing-app) it
### Makefile
Make commands:
|Command|Description|
|:-----:|:----------|
|`make format`|Format the code using AutoPEP8|
|`make check`|Check the code with linters (MyPy, Pylint)|
|`make dev`|Run the app in development mode|
|`make prod`|Run a production server|
|`make docker`|Build a docker image from `Dockerfile`|
|`make clean`|Clean all cache|
## Structure ## Structure
### Configuration ### Configuration
@ -27,10 +59,9 @@ Includes Jinja, WTForms, MySQL ORM and Docker.
#### The main config loaded by `app/common.py`: #### The main config loaded by `app/common.py`:
- `templates_dir`, `static_dir` contain the paths - `templates_dir`, `static_dir` contain the paths
to templates and static files directories correspondingly to templates and static files directories correspondingly
- `debug_env` is a path to `.env_debug`
- `is_debug` is True when DEBUG env variable is set,
and then `.env_debug` is loaded automatically
- `settings` (pydantic.BaseSettings): - `settings` (pydantic.BaseSettings):
- `debug` means the application is running
in a development environment
- `session_key`, `csrf_key` are secret keys - `session_key`, `csrf_key` are secret keys
for WTForms CSRF protection; for WTForms CSRF protection;
generated with secrets.token_urlsafe generated with secrets.token_urlsafe
@ -66,7 +97,7 @@ You can
copying the contents of `pages.py` copying the contents of `pages.py`
> **Note** > **Note**
> In the paths files FastAPI's decorators > In the paths files, FastAPI's decorators
are called with `@self.app.`, not just `@app.` are called with `@self.app.`, not just `@app.`
In case of deleteing/renaming/creating any paths files, In case of deleteing/renaming/creating any paths files,
@ -92,36 +123,6 @@ TODO
TODO TODO
## Usage
<!--
1. Create a repository from this template
2. For debugging, open `.env_debug` file and
set host, user, password corresponding to
the configuration of MySQL/MariaDB on your PC
3. Edit `paths/pages.py`: add your own paths
in the `add_paths` method
4. Create your own Jinja templates in `templates/` directory,
I recommend to edit `base.html` and inherit other templates from it
5. Edit and rename `forms/users.py`, it contains WTForms classes
6. Customize error pages and add your own paths ...
7. Edit `db/schema.sql` corresponding to your database structure
8. Check `Makefile`, `Dockerfile`, `docker-compose.yml`
9. Run formatter and linters (`make format`, then `make check`)
-->
### Makefile
Make commands:
|Command|Description|
|:-----:|:----------|
|`make format`|Format the code using AutoPEP8|
|`make check`|Check the code with linters (MyPy, Pylint)|
|`make dev`|Run the app in development mode|
|`make prod`|Run a production server|
|`make docker`|Build a docker image from `Dockerfile`|
|`make clean`|Clean all cache|
## Helper functions ## Helper functions
### `respond.py` ### `respond.py`

View file

@ -1,13 +1,11 @@
import os
import secrets import secrets
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseSettings from pydantic import BaseSettings
# Directories
file_dir = Path(__file__).parent file_dir = Path(__file__).parent
templates_dir = str( templates_dir = str(
file_dir.parent / 'templates' file_dir.parent / 'templates'
@ -15,23 +13,19 @@ templates_dir = str(
static_dir = str( static_dir = str(
file_dir.parent / 'static' file_dir.parent / 'static'
) )
debug_env = str(
file_dir.parent / '.env_debug'
)
is_debug = bool(os.getenv('DEBUG'))
if is_debug:
load_dotenv(debug_env)
# Main configuration
class Settings(BaseSettings): class Settings(BaseSettings):
debug: bool = False
session_key: str = secrets.token_hex(32) session_key: str = secrets.token_hex(32)
csrf_key: str = secrets.token_hex(32) csrf_key: str = secrets.token_hex(32)
settings = Settings() settings = Settings()
# Jinja templates handler
templates = Jinja2Templates( templates = Jinja2Templates(
directory=templates_dir, directory=templates_dir,
) )

View file

@ -25,5 +25,5 @@ async def get_form(
Returns: Returns:
StarletteForm instance StarletteForm instance
""" """
return await form.from_formdata(request=req) return await form.from_formdata(request=req)

View file

@ -7,7 +7,7 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette_wtf import CSRFProtectMiddleware from starlette_wtf import CSRFProtectMiddleware
from . import common from . import common
from .sql import db from . import sql
from .paths import Paths from .paths import Paths
# Add your paths below # Add your paths below
@ -22,16 +22,24 @@ paths: List[Type[Paths]] = [
] ]
db.Base.metadata.create_all(bind=db.engine) # Initialize SQL database
sql.Base.metadata.create_all(bind=sql.engine)
# Create app
app = FastAPI() app = FastAPI()
# Mount static files server
app.mount( app.mount(
'/static', '/static',
StaticFiles(directory=common.static_dir), StaticFiles(directory=common.static_dir),
name='static', name='static',
) )
# Add paths
for p in paths: for p in paths:
p(app).add_paths() p(app).add_paths()
# Add WTForms CSRF protection middlewares
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=common.settings.session_key, secret_key=common.settings.session_key,

View file

@ -17,7 +17,7 @@ class Paths(abc.ABC):
""" """
self.app = app self.app = app
@abc.abstractmethod @abc.abstractmethod
def add_paths(self) -> None: def add_paths(self) -> None:
"""Add paths to the FastAPI application""" """Add paths to the FastAPI application"""

View file

@ -20,9 +20,10 @@ class ErrorsPaths(Paths):
def add_paths(self) -> None: def add_paths(self) -> None:
# For each HTTP code specified above
for code in codes: for code in codes:
self.add_handler(code) self.add_handler(code)
def add_handler(self, code: int) -> None: def add_handler(self, code: int) -> None:
"""Adds an error handler to FastAPI app. """Adds an error handler to FastAPI app.
Only for internal use! Only for internal use!
@ -31,14 +32,19 @@ class ErrorsPaths(Paths):
code (int): HTTP error code code (int): HTTP error code
""" """
# Jinja template file name
# e.g. 404.html for 404 code
file = Path(common.templates_dir) / f'{code}.html' file = Path(common.templates_dir) / f'{code}.html'
# Exit from the function
# if the template does not exist
if not file.exists(): if not file.exists():
return return
@self.app.exception_handler(code) @self.app.exception_handler(code)
async def handler(req: Request, exc: HTTPException) -> Response: async def handler(req: Request, exc: HTTPException) -> Response:
# Respond with the template
return respond.with_tmpl( return respond.with_tmpl(
f'{code}.html', f'{code}.html',
code=code, code=code,

View file

@ -9,7 +9,7 @@ from starlette_wtf import csrf_protect
from . import Paths from . import Paths
from .. import respond from .. import respond
from ..sql import db from .. import sql
from ..sql import crud from ..sql import crud
from ..sql import schemas from ..sql import schemas
from ..forms import get_form from ..forms import get_form
@ -26,7 +26,7 @@ class TablePaths(Paths):
def list_users( def list_users(
req: Request, req: Request,
page: int = 0, page: int = 0,
db: Session = Depends(db.get_db)) -> Response: db: Session = Depends(sql.get_db)) -> Response:
return respond.with_tmpl( return respond.with_tmpl(
'table.html', 'table.html',
@ -43,7 +43,7 @@ class TablePaths(Paths):
@csrf_protect @csrf_protect
async def add_form( async def add_form(
req: Request, req: Request,
db_s: Session = Depends(db.get_db)) -> Response: db_s: Session = Depends(sql.get_db)) -> Response:
form = await get_form(AddUserForm, req) form = await get_form(AddUserForm, req)
@ -51,7 +51,7 @@ class TablePaths(Paths):
if form.pswd.data != '1234': if form.pswd.data != '1234':
return respond.with_text('Incorrect password') return respond.with_text('Incorrect password')
crud.create_user( crud.create_user(
db=db_s, db=db_s,
user=schemas.UserCreate( user=schemas.UserCreate(

View file

@ -16,12 +16,12 @@ from .common import templates
def with_redirect( def with_redirect(
url: str = '/', url: str = '/',
code: int = 302, code: int = 302,
*args, **kwargs) -> RedirectResponse: **kwargs) -> RedirectResponse:
"""Return a redirect to the page specified in `url`. """Return a redirect to the page specified in `url`.
By default, code is 302 so method is changed to GET. By default, code is 302 so method is changed to GET.
To leave the same HTTP method, use 307 status code To leave the same HTTP method, use 307 status code
or call `with_redirect_307` function. or call `with_redirect_307` function.
`args` and `kwargs` are passed directly `kwargs` are passed directly
to the Response contructor to the Response contructor
Args: Args:
@ -35,16 +35,16 @@ def with_redirect(
return RedirectResponse( return RedirectResponse(
url=url, url=url,
status_code=code, status_code=code,
*args, **kwargs, **kwargs,
) )
def with_text( def with_text(
content: str, content: str,
code: int = 200, code: int = 200,
*args, **kwargs) -> PlainTextResponse: **kwargs) -> PlainTextResponse:
"""Return a plain text to the user. """Return a plain text to the user.
`args` and `kwargs` are passed directly `kwargs` are passed directly
to the Response contructor to the Response contructor
Args: Args:
@ -58,7 +58,7 @@ def with_text(
return PlainTextResponse( return PlainTextResponse(
content=content, content=content,
status_code=code, status_code=code,
*args, **kwargs, **kwargs,
) )
@ -117,10 +117,10 @@ def with_file(
path: os.PathLike, path: os.PathLike,
mime: Optional[str] = None, mime: Optional[str] = None,
code: int = 200, code: int = 200,
*args, **kwargs) -> FileResponse: **kwargs) -> FileResponse:
"""Send the file specified in `path` """Send the file specified in `path`
automatically guessing its mimetype if `mime` is None. automatically guessing its mimetype if `mime` is None.
`args` and `kwargs` are passed directly `kwargs` are passed directly
to the Response contructor to the Response contructor
Args: Args:
@ -135,11 +135,11 @@ def with_file(
return FileResponse( return FileResponse(
path=path, path=path,
media_type=( media_type=(
mime or mime or
mimetypes.guess_type(path)[0] mimetypes.guess_type(path)[0]
), ),
status_code=code, status_code=code,
*args, **kwargs, **kwargs,
) )

View file

@ -3,12 +3,14 @@ from typing import AsyncGenerator
from pydantic import BaseSettings from pydantic import BaseSettings
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy_utils import database_exists
from sqlalchemy_utils import create_database
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy_utils import database_exists
from sqlalchemy_utils import create_database
# Database configuration
class SqlSettings(BaseSettings): class SqlSettings(BaseSettings):
db_host: str = '${REPO_NAME_SNAKE}_db' db_host: str = '${REPO_NAME_SNAKE}_db'
db_port: int = 3306 db_port: int = 3306
@ -16,26 +18,36 @@ class SqlSettings(BaseSettings):
db_password: str = '' db_password: str = ''
db_database: str = '${REPO_NAME_SNAKE}' db_database: str = '${REPO_NAME_SNAKE}'
sql_settings = SqlSettings() sql_settings = SqlSettings()
# DB connection URL
# pylint: disable=consider-using-f-string
db_url = ( db_url = (
'mysql://{db_user}:{db_password}@' 'mysql://{db_user}:{db_password}@'
'{db_host}:{db_port}/{db_database}' '{db_host}:{db_port}/{db_database}'
).format(**sql_settings.dict()) ).format(**sql_settings.dict())
# pylint: enable=consider-using-f-string
# SQLAlchemy engine object
engine = create_engine(db_url) engine = create_engine(db_url)
# Create DB if not exists
if not database_exists(db_url): if not database_exists(db_url):
create_database(db_url) create_database(db_url)
# SQLAlchemy Session object
SessionLocal = sessionmaker( SessionLocal = sessionmaker(
autoflush=False, autoflush=False,
bind=engine, bind=engine,
) )
# SQLAlchemy Base object
Base = declarative_base() Base = declarative_base()
# FastAPI dependency
async def get_db() -> AsyncGenerator[Session, None]: async def get_db() -> AsyncGenerator[Session, None]:
"""FastAPI dependency """FastAPI dependency
returning database Session object. returning database Session object.

View file

@ -6,6 +6,7 @@ from . import models
from . import schemas from . import schemas
# SELECT * from users LIMIT 1
def get_user( def get_user(
db: Session, db: Session,
user_id: int) -> Optional[models.User]: user_id: int) -> Optional[models.User]:
@ -16,6 +17,7 @@ def get_user(
.first() .first()
# SELECT * from users
def get_users( def get_users(
db: Session, db: Session,
skip: int = 0, skip: int = 0,
@ -28,6 +30,7 @@ def get_users(
.all() .all()
# INSERT INTO users
def create_user( def create_user(
db: Session, db: Session,
user: schemas.UserCreate) -> models.User: user: schemas.UserCreate) -> models.User:

View file

@ -1,6 +1,6 @@
from sqlalchemy import Column, String, Integer from sqlalchemy import Column, String, Integer
from .db import Base from . import Base
class User(Base): class User(Base):

View file

@ -1,12 +1,16 @@
from pydantic import BaseModel from pydantic import BaseModel # pylint: disable=no-name-in-module
# Pydantic class for
# INSERT queries on a User model
class UserCreate(BaseModel): class UserCreate(BaseModel):
email: str email: str
name: str name: str
age: int age: int
# Pydantic class for
# SELECT responses with User model(-s)
class User(UserCreate): class User(UserCreate):
id: int id: int

4
mypy.ini Normal file
View file

@ -0,0 +1,4 @@
[mypy]
show_error_codes = True
ignore_missing_imports = True
warn_redundant_casts = True

224
pylintrc
View file

@ -1,5 +1,6 @@
[MAIN] [MAIN]
analyse-fallback-blocks=no analyse-fallback-blocks=no
clear-cache-post-run=no
extension-pkg-allow-list= extension-pkg-allow-list=
extension-pkg-whitelist= extension-pkg-whitelist=
fail-on= fail-on=
@ -17,113 +18,6 @@ recursive=no
suggestion-mode=yes suggestion-mode=yes
unsafe-load-any-extension=no unsafe-load-any-extension=no
[REPORTS]
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
msg-template=
reports=no
score=yes
[MESSAGES CONTROL]
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead
enable=c-extension-no-member
[SIMILARITIES]
ignore-comments=yes
ignore-docstrings=yes
ignore-imports=yes
ignore-signatures=yes
min-similarity-lines=4
[MISCELLANEOUS]
notes=FIXME,
XXX,
TODO
notes-rgx=
[DESIGN]
exclude-too-few-public-methods=
ignored-parents=
max-args=5
max-attributes=7
max-bool-expr=5
max-branches=12
max-locals=15
max-parents=7
max-public-methods=20
max-returns=6
max-statements=50
min-public-methods=1
[STRING]
check-quote-consistency=no
check-str-concat-over-line-jumps=no
[CLASSES]
check-protected-access-in-special-methods=no
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
valid-classmethod-first-arg=cls
valid-metaclass-classmethod-first-arg=cls
[FORMAT]
expected-line-ending-format=
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
indent-after-paren=4
indent-string=' '
max-line-length=100
max-module-lines=1000
single-line-class-stmt=no
single-line-if-stmt=no
[IMPORTS]
allow-any-import-level=
allow-wildcard-with-all=no
deprecated-modules=
ext-import-graph=
import-graph=
int-import-graph=
known-standard-library=
known-third-party=enchant
preferred-modules=
[VARIABLES]
additional-builtins=
allow-global-unused-variables=yes
allowed-redefined-builtins=
callbacks=cb_,
_cb
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
ignored-argument-names=_.*|^ignored_|^unused_
init-import=no
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[LOGGING]
logging-format-style=old
logging-modules=logging
[EXCEPTIONS]
overgeneral-exceptions=BaseException,
Exception
[BASIC] [BASIC]
argument-naming-style=snake_case argument-naming-style=snake_case
attr-naming-style=snake_case attr-naming-style=snake_case
@ -158,6 +52,105 @@ no-docstring-rgx=^_
property-classes=abc.abstractproperty property-classes=abc.abstractproperty
variable-naming-style=snake_case variable-naming-style=snake_case
[CLASSES]
check-protected-access-in-special-methods=no
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
valid-classmethod-first-arg=cls
valid-metaclass-classmethod-first-arg=mcs
[DESIGN]
exclude-too-few-public-methods=
ignored-parents=
max-args=5
max-attributes=7
max-bool-expr=5
max-branches=12
max-locals=15
max-parents=7
max-public-methods=20
max-returns=6
max-statements=50
min-public-methods=0
[EXCEPTIONS]
overgeneral-exceptions=builtins.BaseException,builtins.Exception
[FORMAT]
expected-line-ending-format=
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
indent-after-paren=4
indent-string=' '
max-line-length=100
max-module-lines=1000
single-line-class-stmt=no
single-line-if-stmt=no
[IMPORTS]
allow-any-import-level=
allow-reexport-from-package=no
allow-wildcard-with-all=no
deprecated-modules=
ext-import-graph=
import-graph=
int-import-graph=
known-standard-library=
known-third-party=enchant
preferred-modules=
[LOGGING]
logging-format-style=old
logging-modules=logging
[MESSAGES CONTROL]
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead
enable=c-extension-no-member
[METHOD_ARGS]
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
notes=FIXME,
XXX,
TODO
notes-rgx=
[REFACTORING]
max-nested-blocks=5
never-returning-functions=sys.exit,argparse.parse_error
[REPORTS]
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
msg-template=
reports=no
score=yes
[SIMILARITIES]
ignore-comments=yes
ignore-docstrings=yes
ignore-imports=yes
ignore-signatures=yes
min-similarity-lines=4
[SPELLING] [SPELLING]
max-spelling-suggestions=4 max-spelling-suggestions=4
spelling-dict= spelling-dict=
@ -166,6 +159,10 @@ spelling-ignore-words=
spelling-private-dict-file= spelling-private-dict-file=
spelling-store-unknown-words=no spelling-store-unknown-words=no
[STRING]
check-quote-consistency=no
check-str-concat-over-line-jumps=no
[TYPECHECK] [TYPECHECK]
contextmanager-decorators=contextlib.contextmanager contextmanager-decorators=contextlib.contextmanager
generated-members= generated-members=
@ -182,6 +179,13 @@ missing-member-max-choices=1
mixin-class-rgx=.*[Mm]ixin mixin-class-rgx=.*[Mm]ixin
signature-mutators= signature-mutators=
[REFACTORING] [VARIABLES]
max-nested-blocks=5 additional-builtins=
never-returning-functions=sys.exit,argparse.parse_error allow-global-unused-variables=yes
allowed-redefined-builtins=
callbacks=cb_,
_cb
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
ignored-argument-names=_.*|^ignored_|^unused_
init-import=no
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io