├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── api.py ├── app.py ├── config ├── __init__.py ├── local.py ├── staging.py └── testing.py ├── database.py ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 40aa1a9694cf_.py ├── requirements.txt ├── tasks.py ├── tests ├── __init__.py ├── client.py ├── command.py ├── conftest.py ├── factories.py └── test_api.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # python caches 3 | __pycache__/ 4 | *.py[cod] 5 | 6 | # pytest 7 | .cache/ 8 | 9 | # pycharm 10 | .idea/ 11 | 12 | # celery 13 | celerybeat* 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | addons: 6 | postgresql: "9.4" 7 | install: pip install -r requirements.txt 8 | services: postgresql 9 | before_script: 10 | - psql -c 'create database flask_example_test;' -U postgres 11 | - psql -c 'create database flask_example;' -U postgres 12 | - ./manage.py db upgrade 13 | script: ./manage.py test 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 SUNSCRAPERS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/sunscrapers/flask-boilerplate.svg?branch=master)](https://travis-ci.org/sunscrapers/flask-boilerplate) 2 | 3 | # Flask Boilerplate 4 | 5 | This repository contains a sample minimal Flask application structure that includes: 6 | 7 | * SQLAlchemy 8 | * Alembic 9 | * Celery 10 | * py.test 11 | 12 | It runs on both Python 2.7 and 3.5. 13 | 14 | ## Installation 15 | 16 | First, clone the repository and create a virtualenv. Then install the requirements: 17 | 18 | `$ pip install -r requirements.txt` 19 | 20 | Before running the application make sure that your local PostgreSQL server is up. Then create the databases: 21 | 22 | ``` 23 | CREATE DATABASE flask_example; 24 | CREATE DATABASE flask_example_test; 25 | ``` 26 | 27 | Now you can create the tables using Alembic: 28 | 29 | `./manage.py db upgrade` 30 | 31 | Finally you can run the application: 32 | 33 | `./manage.py runserver` 34 | 35 | or play in the Python REPL: 36 | 37 | `./manage.py shell` 38 | 39 | In order to run unit tests in py.test invoke: 40 | 41 | `./manage.py test` 42 | 43 | 44 | ## Contribution 45 | 46 | We are happy to see your way of scaffolding Flask applications. Feel free to submit an issue with your ideas or comments. 47 | -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask.ext.restful import Resource, Api 3 | from werkzeug.exceptions import HTTPException 4 | 5 | from database import Document 6 | 7 | 8 | class NotFound(HTTPException): 9 | code = 404 10 | data = {} 11 | 12 | 13 | class DocumentsApi(Api): 14 | 15 | def init_app(self, app): 16 | super(DocumentsApi, self).init_app(app) 17 | app.after_request(self.add_cors_headers) 18 | 19 | def add_cors_headers(self, response): 20 | response.headers.add('Access-Control-Allow-Origin', '*') 21 | response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') 22 | response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE') 23 | return response 24 | 25 | 26 | class DocumentsResource(Resource): 27 | default_length = 100 28 | 29 | def get(self, document_id=None): 30 | if document_id: 31 | return self.get_one(document_id) 32 | return self.get_list() 33 | 34 | def get_one(self, document_id): 35 | document = Document.query.get(document_id) 36 | return document.as_dict() 37 | 38 | def get_list(self): 39 | query = self.paginate(Document.query) 40 | documents = [row.as_dict() for row in query] 41 | return documents 42 | 43 | def paginate(self, query): 44 | offset = int(request.args.get('start', 0)) 45 | limit = int(request.args.get('length', self.default_length)) 46 | if offset < 0 or limit < 0: 47 | raise NotFound() 48 | entries = query.limit(limit).offset(offset).all() 49 | if not entries: 50 | raise NotFound() 51 | 52 | return entries 53 | 54 | 55 | api = DocumentsApi() 56 | api.add_resource(DocumentsResource, '/documents/', '/documents') 57 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | from flask import Flask 5 | 6 | from database import migrate, db 7 | from api import api 8 | 9 | 10 | config_variable_name = 'FLASK_CONFIG_PATH' 11 | default_config_path = os.path.join(os.path.dirname(__file__), 'config/local.py') 12 | os.environ.setdefault(config_variable_name, default_config_path) 13 | 14 | 15 | def create_app(config_file=None, settings_override=None): 16 | app = Flask(__name__) 17 | 18 | if config_file: 19 | app.config.from_pyfile(config_file) 20 | else: 21 | app.config.from_envvar(config_variable_name) 22 | 23 | if settings_override: 24 | app.config.update(settings_override) 25 | 26 | init_app(app) 27 | 28 | return app 29 | 30 | 31 | def init_app(app): 32 | db.init_app(app) 33 | migrate.init_app(app, db) 34 | api.init_app(app) 35 | 36 | 37 | def create_celery_app(app=None): 38 | app = app or create_app() 39 | celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL']) 40 | celery.conf.update(app.config) 41 | TaskBase = celery.Task 42 | 43 | class ContextTask(TaskBase): 44 | abstract = True 45 | 46 | def __call__(self, *args, **kwargs): 47 | with app.app_context(): 48 | return TaskBase.__call__(self, *args, **kwargs) 49 | 50 | celery.Task = ContextTask 51 | return celery 52 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunscrapers/flask-boilerplate/a5572a02b6d23bb9a1377cb549c195b205fd250d/config/__init__.py -------------------------------------------------------------------------------- /config/local.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | DEBUG = True 4 | SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/flask_example' 5 | # SQLALCHEMY_ECHO = True 6 | CELERY_BROKER_URL = 'sqla+postgresql://localhost/flask_example' 7 | CELERY_TASK_SERIALIZER = 'json' 8 | CELERY_ACCEPT_CONTENT = ['json'] 9 | CELERYBEAT_SCHEDULE = { 10 | 'example_task': { 11 | 'task': 'tasks.example_task', 12 | 'schedule': timedelta(seconds=10), 13 | 'args': () 14 | }, 15 | } 16 | 17 | ERROR_404_HELP = False 18 | -------------------------------------------------------------------------------- /config/staging.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import timedelta 3 | 4 | DEBUG = True 5 | SQLALCHEMY_DATABASE_URI = os.environ['SQLALCHEMY_DATABASE_URI'] 6 | CELERY_BROKER_URL = os.environ['CELERY_BROKER_URL'] 7 | CELERY_TASK_SERIALIZER = 'json' 8 | CELERY_ACCEPT_CONTENT = ['json'] 9 | CELERYBEAT_SCHEDULE = { 10 | 'example_task': { 11 | 'task': 'tasks.example_task', 12 | 'schedule': timedelta(minutes=2), 13 | 'args': () 14 | }, 15 | } 16 | 17 | ERROR_404_HELP = False 18 | -------------------------------------------------------------------------------- /config/testing.py: -------------------------------------------------------------------------------- 1 | 2 | DEBUG = True 3 | TESTING = True 4 | SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/flask_example_test' 5 | 6 | ERROR_404_HELP = False 7 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | from flask.ext.migrate import Migrate 2 | from flask.ext.sqlalchemy import SQLAlchemy 3 | from sqlalchemy import Column, Integer 4 | from sqlalchemy.dialects.postgresql import JSONB 5 | from sqlalchemy.ext.mutable import MutableDict 6 | 7 | 8 | migrate = Migrate() 9 | db = SQLAlchemy() 10 | 11 | 12 | class Document(db.Model): 13 | id = Column(Integer(), primary_key=True) 14 | data = Column(MutableDict.as_mutable(JSONB), nullable=False) 15 | 16 | def __init__(self, data): 17 | self.data = data 18 | 19 | def __repr__(self): 20 | return u''.format(self.id) 21 | 22 | def as_dict(self): 23 | data = {'id': self.id} 24 | data.update(self.data) 25 | return data 26 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from flask.ext.script import Manager, Command 4 | from flask.ext.migrate import MigrateCommand 5 | 6 | from app import create_app 7 | from tasks import run_celery 8 | from tests.command import PytestCommand 9 | 10 | 11 | manager = Manager(create_app) 12 | manager.add_option('-c', '--config', dest='config_file', required=False) 13 | manager.add_command('db', MigrateCommand) 14 | manager.add_command('test', PytestCommand) 15 | manager.add_command('runcelery', Command(run_celery)) 16 | 17 | if __name__ == '__main__': 18 | manager.run() 19 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | from flask import current_app 19 | config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI')) 20 | target_metadata = current_app.extensions['migrate'].db.metadata 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure(url=url) 42 | 43 | with context.begin_transaction(): 44 | context.run_migrations() 45 | 46 | 47 | def run_migrations_online(): 48 | """Run migrations in 'online' mode. 49 | 50 | In this scenario we need to create an Engine 51 | and associate a connection with the context. 52 | 53 | """ 54 | engine = engine_from_config(config.get_section(config.config_ini_section), 55 | prefix='sqlalchemy.', 56 | poolclass=pool.NullPool) 57 | 58 | connection = engine.connect() 59 | context.configure(connection=connection, 60 | target_metadata=target_metadata, 61 | **current_app.extensions['migrate'].configure_args) 62 | 63 | try: 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | finally: 67 | connection.close() 68 | 69 | if context.is_offline_mode(): 70 | run_migrations_offline() 71 | else: 72 | run_migrations_online() 73 | 74 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/40aa1a9694cf_.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Revision ID: 40aa1a9694cf 4 | Revises: None 5 | Create Date: 2016-03-22 00:08:31.860252 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '40aa1a9694cf' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | op.create_table('document', 19 | sa.Column('id', sa.Integer(), nullable=False), 20 | sa.Column('data', postgresql.JSONB(), nullable=False), 21 | sa.PrimaryKeyConstraint('id') 22 | ) 23 | 24 | 25 | def downgrade(): 26 | op.drop_table('document') 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==0.8.0 2 | celery==3.1.18 3 | factory-boy==2.5.2 4 | Flask==0.10.1 5 | Flask-Migrate==1.5.0 6 | Flask-RESTful==0.3.5 7 | Flask-Script==2.0.5 8 | Flask-SQLAlchemy==2.0 9 | Jinja2==2.8 10 | six==1.9.0 11 | SQLAlchemy==1.0.8 12 | SQLAlchemy-Utils==0.30.16 13 | Werkzeug==0.10.4 14 | psycopg2==2.6.1 15 | pytest==2.8.5 16 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from app import create_celery_app 2 | 3 | celery = create_celery_app() 4 | 5 | 6 | def run_celery(): 7 | celery.worker_main(['', '-B']) 8 | 9 | 10 | @celery.task() 11 | def example_task(): 12 | print('Hello celery!') 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunscrapers/flask-boilerplate/a5572a02b6d23bb9a1377cb549c195b205fd250d/tests/__init__.py -------------------------------------------------------------------------------- /tests/client.py: -------------------------------------------------------------------------------- 1 | from flask import json, Response 2 | from werkzeug.utils import cached_property 3 | 4 | 5 | class ApiTestingResponse(Response): 6 | 7 | @cached_property 8 | def json(self): 9 | assert self.mimetype == 'application/json' 10 | return json.loads(self.data) 11 | -------------------------------------------------------------------------------- /tests/command.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask.ext.script import Command 3 | 4 | 5 | class PytestCommand(Command): 6 | """Runs tests""" 7 | capture_all_args = True 8 | 9 | def __call__(self, app=None, *args): 10 | pytest.main(*args) 11 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app import create_app 4 | from database import db as _db 5 | from tests.client import ApiTestingResponse 6 | 7 | 8 | @pytest.yield_fixture(scope='session') 9 | def app(): 10 | app = create_app(config_file='config/testing.py') 11 | app.response_class = ApiTestingResponse 12 | ctx = app.app_context() 13 | ctx.push() 14 | 15 | yield app 16 | 17 | ctx.pop() 18 | 19 | 20 | @pytest.yield_fixture(scope='session') 21 | def db(app): 22 | _db.create_all() 23 | 24 | yield _db 25 | 26 | _db.drop_all() 27 | 28 | 29 | @pytest.fixture(scope='session') 30 | def client(app): 31 | return app.test_client() 32 | 33 | 34 | @pytest.yield_fixture(scope='function') 35 | def session(db): 36 | connection = db.engine.connect() 37 | transaction = connection.begin() 38 | 39 | options = dict(bind=connection) 40 | session = db.create_scoped_session(options=options) 41 | 42 | db.session = session 43 | 44 | yield session 45 | 46 | transaction.rollback() 47 | connection.close() 48 | session.remove() 49 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from database import db, Document 4 | 5 | 6 | class SQLAlchemyModelFactory(factory.Factory): 7 | 8 | class Meta: 9 | abstract = True 10 | 11 | @classmethod 12 | def _create(cls, model_class, *args, **kwargs): 13 | session = db.session 14 | session.begin(nested=True) 15 | obj = model_class(*args, **kwargs) 16 | session.add(obj) 17 | session.commit() 18 | return obj 19 | 20 | 21 | class DocumentFactory(SQLAlchemyModelFactory): 22 | 23 | class Meta: 24 | model = Document 25 | 26 | data = factory.LazyAttribute(lambda x: dict()) 27 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from tests import factories 2 | 3 | 4 | def test_get_should_return_documents(client, session): 5 | documents_count = 3 6 | factories.DocumentFactory.create_batch(documents_count) 7 | 8 | response = client.get('/documents') 9 | 10 | assert response.status_code == 200 11 | assert len(response.json) == documents_count 12 | 13 | 14 | def test_get_should_return_paginated_documents(client, session): 15 | documents_count = 3 16 | factories.DocumentFactory.create_batch(documents_count) 17 | length = 2 18 | start = 0 19 | 20 | response = client.get('/documents?start={0}&length={1}'.format(start, length)) 21 | 22 | assert response.status_code == 200 23 | assert len(response.json) == length 24 | 25 | 26 | def test_get_documents_should_return_404_when_missing_page(client, session): 27 | documents_count = 3 28 | factories.DocumentFactory.create_batch(documents_count) 29 | 30 | response = client.get('/documents?start=3') 31 | 32 | assert response.status_code == 404 33 | assert response.json == {} 34 | 35 | 36 | def test_get_should_return_one_document(client, session): 37 | name = 'John' 38 | document = factories.DocumentFactory.create(data={'name': name}) 39 | 40 | response = client.get('/documents/{id}'.format(id=document.id)) 41 | 42 | assert response.status_code == 200 43 | assert response.json['name'] == name 44 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | application = create_app() 4 | --------------------------------------------------------------------------------