Compare commits

...

7 commits

16 changed files with 218 additions and 180 deletions

View file

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

View file

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

View file

@ -14,6 +14,38 @@ Includes Jinja, WTForms, MySQL ORM and Docker.
- 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
### Configuration
@ -27,10 +59,9 @@ Includes Jinja, WTForms, MySQL ORM and Docker.
#### The main config loaded by `app/common.py`:
- `templates_dir`, `static_dir` contain the paths
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):
- `debug` means the application is running
in a development environment
- `session_key`, `csrf_key` are secret keys
for WTForms CSRF protection;
generated with secrets.token_urlsafe
@ -66,7 +97,7 @@ You can
copying the contents of `pages.py`
> **Note**
> In the paths files FastAPI's decorators
> In the paths files, FastAPI's decorators
are called with `@self.app.`, not just `@app.`
In case of deleteing/renaming/creating any paths files,
@ -92,36 +123,6 @@ 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
### `respond.py`

View file

@ -1,13 +1,11 @@
import os
import secrets
from pathlib import Path
from dotenv import load_dotenv
from fastapi.templating import Jinja2Templates
from pydantic import BaseSettings
# Directories
file_dir = Path(__file__).parent
templates_dir = str(
file_dir.parent / 'templates'
@ -15,23 +13,19 @@ templates_dir = str(
static_dir = str(
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):
debug: bool = False
session_key: str = secrets.token_hex(32)
csrf_key: str = secrets.token_hex(32)
settings = Settings()
# Jinja templates handler
templates = Jinja2Templates(
directory=templates_dir,
)

View file

@ -7,7 +7,7 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette_wtf import CSRFProtectMiddleware
from . import common
from .sql import db
from . import sql
from .paths import Paths
# 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()
# Mount static files server
app.mount(
'/static',
StaticFiles(directory=common.static_dir),
name='static',
)
# Add paths
for p in paths:
p(app).add_paths()
# Add WTForms CSRF protection middlewares
app.add_middleware(
SessionMiddleware,
secret_key=common.settings.session_key,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
from sqlalchemy import Column, String, Integer
from .db import Base
from . import 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):
email: str
name: str
age: int
# Pydantic class for
# SELECT responses with User model(-s)
class User(UserCreate):
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]
analyse-fallback-blocks=no
clear-cache-post-run=no
extension-pkg-allow-list=
extension-pkg-whitelist=
fail-on=
@ -17,113 +18,6 @@ recursive=no
suggestion-mode=yes
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]
argument-naming-style=snake_case
attr-naming-style=snake_case
@ -158,6 +52,105 @@ no-docstring-rgx=^_
property-classes=abc.abstractproperty
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]
max-spelling-suggestions=4
spelling-dict=
@ -166,6 +159,10 @@ spelling-ignore-words=
spelling-private-dict-file=
spelling-store-unknown-words=no
[STRING]
check-quote-consistency=no
check-str-concat-over-line-jumps=no
[TYPECHECK]
contextmanager-decorators=contextlib.contextmanager
generated-members=
@ -182,6 +179,13 @@ missing-member-max-choices=1
mixin-class-rgx=.*[Mm]ixin
signature-mutators=
[REFACTORING]
max-nested-blocks=5
never-returning-functions=sys.exit,argparse.parse_error
[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