Add SQLAlchemy and database boilerplate code

This commit is contained in:
Lexi / Zoe 2022-04-02 01:06:59 +02:00
parent 2da6e43797
commit 31df889dcb
Signed by: binaryDiv
GPG Key ID: F8D4956E224DA232
15 changed files with 454 additions and 60 deletions

View File

@ -8,6 +8,7 @@ gunicorn = "~=20.1"
werkzeug = "~=2.0"
flask = "~=2.0"
pyyaml = "*"
sqlalchemy = "~=1.4"
[dev-packages]

116
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "57e212392ff70382ed9ae0637e05f3696fa1d815f226e564910d296f7e2b5027"
"sha256": "c58e87fa3968e9700fc194b98a663138ce212ee9601afcfd1e4478a772041294"
},
"pipfile-spec": 6,
"requires": {
@ -32,6 +32,67 @@
"index": "pypi",
"version": "==2.0.3"
},
"greenlet": {
"hashes": [
"sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3",
"sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711",
"sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd",
"sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073",
"sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708",
"sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67",
"sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23",
"sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1",
"sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08",
"sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd",
"sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2",
"sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa",
"sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8",
"sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40",
"sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab",
"sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6",
"sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc",
"sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b",
"sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e",
"sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963",
"sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3",
"sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d",
"sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d",
"sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe",
"sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28",
"sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3",
"sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e",
"sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c",
"sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d",
"sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0",
"sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497",
"sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee",
"sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713",
"sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58",
"sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a",
"sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06",
"sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88",
"sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965",
"sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f",
"sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4",
"sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5",
"sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c",
"sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a",
"sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1",
"sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43",
"sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627",
"sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b",
"sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168",
"sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d",
"sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5",
"sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478",
"sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf",
"sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce",
"sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c",
"sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"
],
"markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==1.1.2"
},
"gunicorn": {
"hashes": [
"sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e",
@ -50,11 +111,11 @@
},
"jinja2": {
"hashes": [
"sha256:a2f09a92f358b96b5f6ca6ecb4502669c4acb55d8733bbb2b2c9c4af5564c605",
"sha256:da424924c069a4013730d8dd010cbecac7e7bb752be388db3741688bffb48dc6"
"sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119",
"sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.0"
"version": "==3.1.1"
},
"markupsafe": {
"hashes": [
@ -143,11 +204,52 @@
},
"setuptools": {
"hashes": [
"sha256:6221e37dc86fcdc9dad9d9eb2002e9f9798fe4aca1bf18f280e66e50c0eb7fca",
"sha256:ad88b13f3dc60420259c9877486908ddad12c7befaff0d624c7190f742abd64f"
"sha256:63b73d83c4d419d9dd992978cf369af5b6efa06236a4a1397b9bdf3ecadb0453",
"sha256:9bc2671450c57d9e4885e7dcdc552e3b107b80a5a8f8b1a1df7d06f52db194be"
],
"markers": "python_version >= '3.7'",
"version": "==61.0.0"
"version": "==61.1.0"
},
"sqlalchemy": {
"hashes": [
"sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34",
"sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3",
"sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674",
"sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5",
"sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f",
"sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9",
"sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e",
"sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97",
"sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a",
"sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16",
"sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00",
"sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2",
"sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71",
"sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089",
"sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc",
"sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e",
"sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b",
"sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082",
"sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13",
"sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac",
"sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615",
"sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13",
"sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4",
"sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1",
"sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99",
"sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558",
"sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f",
"sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751",
"sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423",
"sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55",
"sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed",
"sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48",
"sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76",
"sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9",
"sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"
],
"index": "pypi",
"version": "==1.4.32"
},
"werkzeug": {
"hashes": [

View File

@ -1,14 +1,15 @@
from flask import Blueprint
from tofu_api.common.rest import BaseBlueprint
from .tasks import TaskApiBlueprint
class TofuApiBlueprint(Blueprint):
class TofuApiBlueprint(BaseBlueprint):
"""
Main blueprint for the Tofu REST API.
"""
def __init__(self):
super().__init__('rest_api', __name__, url_prefix='/api')
name = 'rest_api'
import_name = __name__
url_prefix = '/api'
self.register_blueprint(TaskApiBlueprint())
def init_blueprint(self) -> None:
self.register_blueprint(TaskApiBlueprint(self.app))

View File

@ -1,28 +1,53 @@
from flask import Blueprint, jsonify
from flask import jsonify
from flask.views import MethodView
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from tofu_api.common.rest import BaseBlueprint
from tofu_api.models import Task
class TaskApiBlueprint(Blueprint):
class TaskApiBlueprint(BaseBlueprint):
"""
Blueprint for the tasks REST API.
"""
def __init__(self):
super().__init__('rest_api_tasks', __name__, url_prefix='/tasks')
# Blueprint settings
name = 'rest_api_tasks'
import_name = __name__
url_prefix = '/tasks'
def init_blueprint(self) -> None:
"""
Register URL rules.
"""
db_session = self.app.dependencies.get_db_session()
self.add_url_rule(
'/',
view_func=TaskCollectionView.as_view(TaskCollectionView.__name__),
'',
view_func=self.create_view_func(TaskCollectionView, db_session=db_session),
methods=['GET', 'POST'],
)
self.add_url_rule(
'/<int:task_id>',
view_func=TaskItemView.as_view(TaskItemView.__name__),
view_func=self.create_view_func(TaskItemView, db_session=db_session),
methods=['GET', 'PATCH', 'DELETE'],
)
class TaskCollectionView(MethodView):
class TaskBaseView(MethodView):
"""
Base class for view classes for the `/tasks` endpoint.
"""
# TODO: Use a handler class instead of accessing the database session directly
db_session: Session
def __init__(self, *, db_session: Session):
self.db_session = db_session
class TaskCollectionView(TaskBaseView):
"""
View class for `/tasks` endpoint.
"""
@ -31,28 +56,26 @@ class TaskCollectionView(MethodView):
"""
Get list of all tasks.
"""
# TODO: Get actual data
task_list = self.db_session.query(Task).all()
return jsonify({
'count': 1,
'items': [
{
'id': 1,
'title': 'Do stuff',
'description': '',
'status': 'open',
},
],
'count': len(task_list),
'items': [task.to_dict() for task in task_list],
}), 200
def post(self):
"""
Create a new task.
"""
# TODO: Implement
raise NotImplementedError
# TODO: Parse request data and create real data
new_task = Task(
title='Do stuff'
)
self.db_session.add(new_task)
self.db_session.commit()
return jsonify(new_task.to_dict()), 201
class TaskItemView(MethodView):
class TaskItemView(TaskBaseView):
"""
View class for `/tasks/<int:task_id>` endpoint.
"""
@ -61,13 +84,10 @@ class TaskItemView(MethodView):
"""
Get a single task by ID.
"""
# TODO: Get actual data
return jsonify({
'id': task_id,
'title': 'Do stuff',
'description': '',
'status': 'open',
}), 200
task = self.db_session.query(Task).get(task_id)
if task is None:
raise NotFound(f'Task with ID {task_id} not found!')
return jsonify(task.to_dict()), 200
def patch(self, task_id: int):
"""

View File

@ -5,28 +5,58 @@ from flask import Flask
from tofu_api.api import TofuApiBlueprint
from tofu_api.config import DefaultConfig
from tofu_api.dependencies import Dependencies
class App(Flask):
"""
Flask application for Tofu API.
"""
# Dependencies container
dependencies: Dependencies
def __init__(self):
# Set instance path to the project root directory
project_root_dir = os.path.abspath('.')
# Create and configure the app
super().__init__(
'tofu_api',
instance_path=project_root_dir,
instance_relative_config=True,
)
self.config.from_object(DefaultConfig)
# Load app configuration from YAML file
self.config.from_file(os.getenv('FLASK_CONFIG_FILE', default='config.yml'), load=yaml.safe_load)
# Initialize DI container
self.dependencies = Dependencies()
# Initialize dependencies
self.init_database()
# Register blueprints
self.register_blueprint(TofuApiBlueprint(self))
def init_database(self) -> None:
"""
Initialize database connection and models.
"""
# Initialize SQLAlchemy, create the database engine based on the app config
db = self.dependencies.get_sqlalchemy()
db.init_database(self)
# Import models to fill the metadata object
import tofu_api.models # noqa (unused import)
# Create all tables
# TODO: Use migrations instead
db.create_all_tables()
def create_app() -> Flask:
"""
App factory, returns a Flask app object.
"""
# Set instance path to the project root directory
project_root_dir = os.path.abspath('.')
# Create and configure the app
app = Flask(
'tofu_api',
instance_path=project_root_dir,
instance_relative_config=True,
)
app.config.from_object(DefaultConfig)
# Load app configuration from YAML file
app.config.from_file(os.getenv('FLASK_CONFIG_FILE', default='config.yml'), load=yaml.safe_load)
# Register blueprints
app.register_blueprint(TofuApiBlueprint())
# Return WSGI app
return app
return App()

View File

View File

@ -0,0 +1,3 @@
from .metadata import metadata_obj
from .model import Model
from .sqlalchemy import SQLAlchemy

View File

@ -0,0 +1,17 @@
from sqlalchemy import MetaData
__all__ = [
'metadata_obj',
]
# Define naming convention for constraints
_naming_convention = {
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
# Create global metadata object for database schemas
metadata_obj = MetaData(naming_convention=_naming_convention)

View File

@ -0,0 +1,10 @@
from sqlalchemy.orm import declarative_base
from .metadata import metadata_obj
__all__ = [
'Model',
]
# Generate declarative base class for database models
Model = declarative_base(name='Model', metadata=metadata_obj)

View File

@ -0,0 +1,87 @@
from typing import Optional, cast
from flask import Flask
from sqlalchemy import MetaData, create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from .metadata import metadata_obj
__all__ = [
'SQLAlchemy',
]
class SQLAlchemy:
"""
Wrapper class for integrating SQLAlchemy into the application as a Flask extension.
"""
_engine: Optional[Engine] = None
_scoped_session: Optional[scoped_session] = None
def init_database(self, app: Flask):
"""
Initializes the SQLAlchemy engine.
"""
self._engine = self._create_engine()
self._scoped_session = self._create_scoped_session()
@app.teardown_appcontext
def shutdown_session(_exception=None):
self._scoped_session.remove()
def _create_engine(self) -> Engine:
"""
Create the database engine using the app configuration.
"""
# TODO: Use config
return create_engine('sqlite:////tmp/test.db')
def _create_scoped_session(self) -> scoped_session:
"""
Create a scoped session.
"""
return scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=self._engine,
)
)
@property
def engine(self) -> Engine:
"""
Database engine.
"""
assert self._engine, 'Engine not ready yet.'
return self._engine
@property
def session(self) -> Session:
"""
Scoped database session.
"""
assert self._scoped_session, 'Session not ready yet.'
# For all further purposes, the scoped session should be treated like a regular Session object.
# Use cast() so we can use Session as the type annotation.
return cast(Session, self._scoped_session)
@property
def metadata(self) -> MetaData:
"""
Database metadata object.
"""
return metadata_obj
def create_all_tables(self) -> None:
"""
Create tables in the database for all models defined in the metadata.
"""
self.metadata.create_all(self.engine)
def drop_all_tables(self) -> None:
"""
Delete tables in the database for all models defined in the metadata.
"""
self.metadata.drop_all(self.engine)

View File

@ -0,0 +1 @@
from .base_blueprint import BaseBlueprint

View File

@ -0,0 +1,72 @@
from abc import ABC, abstractmethod
from typing import Callable, Type, TYPE_CHECKING
from flask import Blueprint
from flask.views import View
if TYPE_CHECKING:
from tofu_api.app import App
__all__ = [
'BaseBlueprint',
]
class BaseBlueprint(Blueprint, ABC):
"""
Base class for Flask blueprints.
"""
# Reference to the application object
app: 'App'
@property
@abstractmethod
def name(self) -> str:
"""
The name of the blueprint. Will be prepended to each endpoint name.
"""
raise NotImplementedError
@property
@abstractmethod
def import_name(self) -> str:
"""
The name of the blueprint package. Should be set to `__name__`.
"""
raise NotImplementedError
@property
@abstractmethod
def url_prefix(self) -> str:
"""
Prefix for all URLs.
"""
raise NotImplementedError
def __init__(self, app: 'App'):
"""
Initialize blueprint. Needs the Flask application object.
"""
super().__init__(
name=self.name,
import_name=self.import_name,
url_prefix=self.url_prefix,
)
self.app = app
self.init_blueprint()
@abstractmethod
def init_blueprint(self) -> None:
"""
Register child blueprints and URL rules.
"""
raise NotImplementedError
@staticmethod
def create_view_func(view_cls: Type[View], *args, **kwargs) -> Callable:
"""
Helper function to create a view function from a `View` class using `view_cls.as_view()`.
All arguments are passed to the constructor of the view class.
"""
return view_cls.as_view(view_cls.__name__, *args, **kwargs)

24
tofu_api/dependencies.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy.orm import Session
from tofu_api.common.database import SQLAlchemy
class Dependencies:
"""
Container for dependency injection.
"""
_dependency_cache: dict
def __init__(self):
self._dependency_cache = {}
# Database dependencies
def get_sqlalchemy(self) -> SQLAlchemy:
if SQLAlchemy not in self._dependency_cache:
self._dependency_cache[SQLAlchemy] = SQLAlchemy()
return self._dependency_cache[SQLAlchemy]
def get_db_session(self) -> Session:
return self.get_sqlalchemy().session

View File

@ -0,0 +1 @@
from .task import Task

25
tofu_api/models/task.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Text
from tofu_api.common.database import Model
class Task(Model):
"""
Database model for tasks.
"""
__tablename__ = 'tasks'
id: int = Column(Integer, nullable=False, primary_key=True)
# TODO: created_at, modified_at
title: str = Column(String(255), nullable=False)
description: str = Column(Text, nullable=False, default='')
def to_dict(self) -> dict:
# TODO: Implement a generic to_dict() in the base model
return {
'id': self.id,
'title': self.title,
'description': self.description,
}