diff --git a/README.rst b/README.rst index 06a22b0..5998d13 100644 --- a/README.rst +++ b/README.rst @@ -25,6 +25,7 @@ Features - Bootstrap 3 and Font Awesome 4 with starter templates - Flask-SQLAlchemy with basic User model - Easy database migrations with Flask-Migrate +- Configuration in environment variables, as per `The Twelve-Factor App `_ - Flask-WTForms with login and registration forms - Flask-Login for authentication - Flask-Bcrypt for password hashing diff --git a/cookiecutter.json b/cookiecutter.json index 0ae232c..c4a689f 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -4,5 +4,6 @@ "github_username": "sloria", "project_name": "My Flask App", "app_name": "myflaskapp", - "project_short_description": "A flasky app." + "project_short_description": "A flasky app.", + "use_pipenv": ["no", "yes"] } diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py new file mode 100644 index 0000000..339ecfa --- /dev/null +++ b/hooks/post_gen_project.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Post gen hook to ensure that the generated project +hase only one package managment, either pipenv or pip.""" +import os +import shutil +import sys + + +def clean_extra_package_managment_files(): + """Removes either requirements files and folderor the Pipfile.""" + use_pipenv = '{{cookiecutter.use_pipenv}}' + to_delete = [] + + if use_pipenv == 'yes': + to_delete = to_delete + ['requirements.txt', 'requirements'] + else: + to_delete.append('Pipfile') + + try: + for file_or_dir in to_delete: + if os.path.isfile(file_or_dir): + os.remove(file_or_dir) + else: + shutil.rmtree(file_or_dir) + sys.exit(0) + except OSError as e: + sys.stdout.write( + 'While attempting to remove file(s) an error occurred' + ) + sys.stdout.write('Error: {}'.format(e)) + + +if __name__ == '__main__': + clean_extra_package_managment_files() diff --git a/tasks.py b/tasks.py index 7e412ce..9d8d5df 100644 --- a/tasks.py +++ b/tasks.py @@ -13,7 +13,6 @@ with open(os.path.join(HERE, 'cookiecutter.json'), 'r') as fp: COOKIECUTTER_SETTINGS = json.load(fp) # Match default value of app_name from cookiecutter.json COOKIE = os.path.join(HERE, COOKIECUTTER_SETTINGS['app_name']) -AUTOAPP = os.path.join(COOKIE, 'autoapp.py') REQUIREMENTS = os.path.join(COOKIE, 'requirements', 'dev.txt') @@ -42,7 +41,8 @@ def clean(ctx): def _run_flask_command(ctx, command): - ctx.run('FLASK_APP={0} flask {1}'.format(AUTOAPP, command), echo=True) + os.chdir(COOKIE) + ctx.run('flask {0}'.format(command), echo=True) @task(pre=[clean, build]) @@ -52,12 +52,14 @@ def test(ctx): echo=True) _run_npm_command(ctx, 'run lint') os.chdir(COOKIE) + shutil.copyfile(os.path.join(COOKIE, '.env.example'), + os.path.join(COOKIE, '.env')) _run_flask_command(ctx, 'lint') _run_flask_command(ctx, 'test') + @task def readme(ctx, browse=False): - ctx.run("rst2html.py README.rst > README.html") + ctx.run('rst2html.py README.rst > README.html') if browse: webbrowser.open_new_tab('README.html') - diff --git a/{{cookiecutter.app_name}}/.env.example b/{{cookiecutter.app_name}}/.env.example new file mode 100644 index 0000000..7236746 --- /dev/null +++ b/{{cookiecutter.app_name}}/.env.example @@ -0,0 +1,5 @@ +# Environment variable overrides for local development +FLASK_APP=autoapp.py +FLASK_ENV=development +DATABASE_URL="sqlite:////tmp/dev.db" +SECRET_KEY="not-so-secret" diff --git a/{{cookiecutter.app_name}}/.gitignore b/{{cookiecutter.app_name}}/.gitignore index be87252..24d2ea2 100644 --- a/{{cookiecutter.app_name}}/.gitignore +++ b/{{cookiecutter.app_name}}/.gitignore @@ -51,3 +51,9 @@ env/ # webpack-built files /{{cookiecutter.app_name}}/static/build/ /{{cookiecutter.app_name}}/webpack/manifest.json + +# Configuration +.env + +# Development database +*.db diff --git a/{{cookiecutter.app_name}}/Pipfile b/{{cookiecutter.app_name}}/Pipfile new file mode 100644 index 0000000..117ba62 --- /dev/null +++ b/{{cookiecutter.app_name}}/Pipfile @@ -0,0 +1,60 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +# Flask +Flask = "==1.0.2" +MarkupSafe = "==1.0" +Werkzeug = "==0.14.1" +Jinja2 = "==2.10" +itsdangerous = "==0.24" +click = ">=5.0" + +# Database +Flask-SQLAlchemy = "==2.3.2" +psycopg2 = "==2.7.5" +SQLAlchemy = "==1.2.8" + +# Migrations +Flask-Migrate = "==2.2.0" + +# Forms +Flask-WTF = "==0.14.2" +WTForms = "==2.2.1" + +# Deployment +gunicorn = ">=19.1.1" + +# Webpack +flask-webpack = "==0.1.0" + +# Auth +Flask-Login = "==0.4.1" +Flask-Bcrypt = "==0.7.1" + +# Caching +Flask-Caching = ">=1.0.0" + +# Debug toolbar +Flask-DebugToolbar = "==0.10.1" + +# Environment variable parsing +environs = "==4.0.0" + +[dev-packages] +# Testing +pytest = "==3.6.1" +WebTest = "==2.0.30" +factory-boy = "==2.11.*" + +# Lint and code style +flake8 = "==3.5.0" +flake8-blind-except = "==0.1.1" +flake8-debugger = "==3.1.0" +flake8-docstrings = "==1.3.0" +flake8-isort = "==2.5" +flake8-quotes = "==1.0.0" +isort = "==4.3.4" +pep8-naming = "==0.7.0" diff --git a/{{cookiecutter.app_name}}/README.rst b/{{cookiecutter.app_name}}/README.rst index 7b4d8d8..0a89530 100644 --- a/{{cookiecutter.app_name}}/README.rst +++ b/{{cookiecutter.app_name}}/README.rst @@ -8,29 +8,21 @@ Quickstart ---------- -First, set your app's secret key as an environment variable. For example, -add the following to ``.bashrc`` or ``.bash_profile``. - -.. code-block:: bash - - export {{cookiecutter.app_name | upper}}_SECRET='something-really-secret' - Run the following commands to bootstrap your environment :: git clone https://github.com/{{cookiecutter.github_username}}/{{cookiecutter.app_name}} cd {{cookiecutter.app_name}} + {%- if cookiecutter.use_pipenv == "yes" %} + pipenv install --dev + {%- else %} pip install -r requirements/dev.txt + {%- endif %} + cp .env.example .env npm install npm start # run the webpack dev server and flask server using concurrently You will see a pretty welcome screen. -In general, before running shell commands, set the ``FLASK_APP`` and -``FLASK_DEBUG`` environment variables :: - - export FLASK_APP=autoapp.py - export FLASK_DEBUG=1 - Once you have installed your DBMS, run the following to create your app's database tables and perform the initial migration :: @@ -45,12 +37,14 @@ Deployment To deploy:: + export FLASK_ENV=production export FLASK_DEBUG=0 + export DATABASE_URL="" npm run build # build assets with webpack flask run # start the flask server In your production environment, make sure the ``FLASK_DEBUG`` environment -variable is unset or is set to ``0``, so that ``ProdConfig`` is used. +variable is unset or is set to ``0``. Shell diff --git a/{{cookiecutter.app_name}}/autoapp.py b/{{cookiecutter.app_name}}/autoapp.py index f7ab4d6..8bcd8c8 100755 --- a/{{cookiecutter.app_name}}/autoapp.py +++ b/{{cookiecutter.app_name}}/autoapp.py @@ -1,10 +1,5 @@ # -*- coding: utf-8 -*- """Create an application instance.""" -from flask.helpers import get_debug_flag - from {{cookiecutter.app_name}}.app import create_app -from {{cookiecutter.app_name}}.settings import DevConfig, ProdConfig - -CONFIG = DevConfig if get_debug_flag() else ProdConfig -app = create_app(CONFIG) +app = create_app() diff --git a/{{cookiecutter.app_name}}/package.json b/{{cookiecutter.app_name}}/package.json index d723d04..e84327f 100644 --- a/{{cookiecutter.app_name}}/package.json +++ b/{{cookiecutter.app_name}}/package.json @@ -6,7 +6,7 @@ "build": "NODE_ENV=production webpack --progress --colors -p", "start": "concurrently -n \"WEBPACK,FLASK\" -c \"bgBlue.bold,bgMagenta.bold\" \"npm run webpack-dev-server\" \"npm run flask-server\"", "webpack-dev-server": "NODE_ENV=debug webpack-dev-server --port 2992 --hot --inline", - "flask-server": "FLASK_APP=$PWD/autoapp.py FLASK_DEBUG=1 flask run", + "flask-server": "flask run", "lint": "eslint \"assets/js/*.js\"" }, "repository": { @@ -28,25 +28,25 @@ }, "devDependencies": { "babel-core": "^6.25.0", - "babel-eslint": "^7.2.3", + "babel-eslint": "^9.0.0", "babel-loader": "^7.0.0", "babel-preset-env": "^1.6.0", - "concurrently": "^3.5.0", - "css-loader": "^0.28.4", - "eslint": "^3.19.0", - "eslint-config-airbnb-base": "^11.2.0", + "concurrently": "^4.0.1", + "css-loader": "^1.0.0", + "eslint": "^5.3.0", + "eslint-config-airbnb-base": "^13.1.0", "eslint-plugin-import": "^2.3.0", "extract-text-webpack-plugin": "^2.1.2", - "file-loader": "^0.11.2", + "file-loader": "^2.0.0", "font-awesome-webpack": "0.0.5-beta.2", - "less": "^2.7.2", + "less": "^3.8.0", "less-loader": "^4.0.4", "manifest-revision-webpack-plugin": "^0.4.0", "raw-loader": "^0.5.1", - "style-loader": "^0.18.2", - "url-loader": "^0.5.9", + "style-loader": "^0.23.0", + "url-loader": "^1.0.1", "webpack": "^2.6.1", - "webpack-dev-server": "^2.4.5", + "webpack-dev-server": "^3.1.5", "sync-exec": "^0.6.2" } } diff --git a/{{cookiecutter.app_name}}/requirements/dev.txt b/{{cookiecutter.app_name}}/requirements/dev.txt index a57e9c9..8c26898 100644 --- a/{{cookiecutter.app_name}}/requirements/dev.txt +++ b/{{cookiecutter.app_name}}/requirements/dev.txt @@ -2,8 +2,8 @@ -r prod.txt # Testing -pytest==3.6.1 -WebTest==2.0.29 +pytest==3.7.4 +WebTest==2.0.30 factory-boy==2.11.1 # Lint and code style diff --git a/{{cookiecutter.app_name}}/requirements/prod.txt b/{{cookiecutter.app_name}}/requirements/prod.txt index f051812..65c2b90 100644 --- a/{{cookiecutter.app_name}}/requirements/prod.txt +++ b/{{cookiecutter.app_name}}/requirements/prod.txt @@ -11,10 +11,10 @@ click>=5.0 # Database Flask-SQLAlchemy==2.3.2 psycopg2==2.7.5 -SQLAlchemy==1.2.8 +SQLAlchemy==1.2.11 # Migrations -Flask-Migrate==2.2.0 +Flask-Migrate==2.2.1 # Forms Flask-WTF==0.14.2 @@ -35,3 +35,6 @@ Flask-Caching>=1.0.0 # Debug toolbar Flask-DebugToolbar==0.10.1 + +# Environment variable parsing +environs==4.0.0 diff --git a/{{cookiecutter.app_name}}/tests/conftest.py b/{{cookiecutter.app_name}}/tests/conftest.py index ab709ff..5bb60c0 100644 --- a/{{cookiecutter.app_name}}/tests/conftest.py +++ b/{{cookiecutter.app_name}}/tests/conftest.py @@ -6,7 +6,6 @@ from webtest import TestApp from {{cookiecutter.app_name}}.app import create_app from {{cookiecutter.app_name}}.database import db as _db -from {{cookiecutter.app_name}}.settings import TestConfig from .factories import UserFactory @@ -14,7 +13,7 @@ from .factories import UserFactory @pytest.fixture def app(): """An application for the tests.""" - _app = create_app(TestConfig) + _app = create_app('tests.settings') ctx = _app.test_request_context() ctx.push() diff --git a/{{cookiecutter.app_name}}/tests/settings.py b/{{cookiecutter.app_name}}/tests/settings.py new file mode 100644 index 0000000..a13e159 --- /dev/null +++ b/{{cookiecutter.app_name}}/tests/settings.py @@ -0,0 +1,11 @@ +"""Settings module for test app.""" +ENV = 'development' +TESTING = True +SQLALCHEMY_DATABASE_URI = 'sqlite://' +SECRET_KEY = 'not-so-secret-in-tests' +BCRYPT_LOG_ROUNDS = 4 # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds" +DEBUG_TB_ENABLED = False +CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. +SQLALCHEMY_TRACK_MODIFICATIONS = False +WEBPACK_MANIFEST_PATH = 'webpack/manifest.json' +WTF_CSRF_ENABLED = False # Allows form testing diff --git a/{{cookiecutter.app_name}}/tests/test_config.py b/{{cookiecutter.app_name}}/tests/test_config.py deleted file mode 100644 index 755bed7..0000000 --- a/{{cookiecutter.app_name}}/tests/test_config.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test configs.""" -from {{cookiecutter.app_name}}.app import create_app -from {{cookiecutter.app_name}}.settings import DevConfig, ProdConfig - - -def test_production_config(): - """Production config.""" - app = create_app(ProdConfig) - assert app.config['ENV'] == 'prod' - assert app.config['DEBUG'] is False - assert app.config['DEBUG_TB_ENABLED'] is False - - -def test_dev_config(): - """Development config.""" - app = create_app(DevConfig) - assert app.config['ENV'] == 'dev' - assert app.config['DEBUG'] is True diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/app.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/app.py index 4740288..0879645 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/app.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/app.py @@ -4,10 +4,9 @@ from flask import Flask, render_template from {{cookiecutter.app_name}} import commands, public, user from {{cookiecutter.app_name}}.extensions import bcrypt, cache, csrf_protect, db, debug_toolbar, login_manager, migrate, webpack -from {{cookiecutter.app_name}}.settings import ProdConfig -def create_app(config_object=ProdConfig): +def create_app(config_object='{{cookiecutter.app_name}}.settings'): """An application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/. :param config_object: The configuration object to use. diff --git a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/settings.py b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/settings.py index 2bf866e..4c920b0 100644 --- a/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/settings.py +++ b/{{cookiecutter.app_name}}/{{cookiecutter.app_name}}/settings.py @@ -1,49 +1,23 @@ # -*- coding: utf-8 -*- -"""Application configuration.""" -import os - - -class Config(object): - """Base configuration.""" - - SECRET_KEY = os.environ.get('{{cookiecutter.app_name | upper}}_SECRET', 'secret-key') # TODO: Change me - APP_DIR = os.path.abspath(os.path.dirname(__file__)) # This directory - PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir)) - BCRYPT_LOG_ROUNDS = 13 - DEBUG_TB_ENABLED = False # Disable Debug toolbar - DEBUG_TB_INTERCEPT_REDIRECTS = False - CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. - SQLALCHEMY_TRACK_MODIFICATIONS = False - WEBPACK_MANIFEST_PATH = 'webpack/manifest.json' - - -class ProdConfig(Config): - """Production configuration.""" - - ENV = 'prod' - DEBUG = False - SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/example' # TODO: Change me - DEBUG_TB_ENABLED = False # Disable Debug toolbar - - -class DevConfig(Config): - """Development configuration.""" - - ENV = 'dev' - DEBUG = True - DB_NAME = 'dev.db' - # Put the db file in project root - DB_PATH = os.path.join(Config.PROJECT_ROOT, DB_NAME) - SQLALCHEMY_DATABASE_URI = 'sqlite:///{0}'.format(DB_PATH) - DEBUG_TB_ENABLED = True - CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. - - -class TestConfig(Config): - """Test configuration.""" - - TESTING = True - DEBUG = True - SQLALCHEMY_DATABASE_URI = 'sqlite://' - BCRYPT_LOG_ROUNDS = 4 # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds" - WTF_CSRF_ENABLED = False # Allows form testing +"""Application configuration. + +Most configuration is set via environment variables. + +For local development, use a .env file to set +environment variables. +""" +from environs import Env + +env = Env() +env.read_env() + +ENV = env.str('FLASK_ENV', default='production') +DEBUG = ENV == 'development' +SQLALCHEMY_DATABASE_URI = env.str('DATABASE_URL') +SECRET_KEY = env.str('SECRET_KEY') +BCRYPT_LOG_ROUNDS = env.int('BCRYPT_LOG_ROUNDS', default=13) +DEBUG_TB_ENABLED = DEBUG +DEBUG_TB_INTERCEPT_REDIRECTS = False +CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. +SQLALCHEMY_TRACK_MODIFICATIONS = False +WEBPACK_MANIFEST_PATH = 'webpack/manifest.json'