diff --git a/.gitignore b/.gitignore index 31496a5..6b0c651 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,7 @@ docs/_build/ target/ # Pycharm -.idea \ No newline at end of file +.idea + +# Mac +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 74ba3fc..fa0a234 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ You will be asked for some basic information regarding your project (name, proje $ cd $ python -m venv env $ source env/bin/activate -$ pip install -r dev_requirements.txt -$ pip-sync dev_requirements.txt requirements.txt ``` + +Follow the initial setup steps included in the project. + #### Run tests ``` -$ export ENVIRONMENT=test && pytest -v +$ env ENVIRONMENT=test pytest -v ``` #### Using the management script @@ -44,8 +45,12 @@ MIT Licensed. ## Changelog +### 0.3.0 (01/27/2019) +- Added migrations. +- Changed testing fixtures. + ### 0.2.0 (01/15/2019) -- Added TOML settings file and .coveragerc file +- Added TOML settings file and .coveragerc file. ### 0.1.0 (12/09/2018) -- Initial release \ No newline at end of file +- Initial release. \ No newline at end of file diff --git a/cookiecutter.json b/cookiecutter.json index 6970bfd..4149f80 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -5,5 +5,7 @@ "project_name": "molten-app", "project_slug": "{{ cookiecutter.project_name.lower().strip().replace(' ', '_').replace('-','_')}}", "description": "An molten project templated by cookiecutter-molten", + "cors_support": "n", + "static_support": "n", "open_source_license": ["MIT license", "BSD license", "ISC license", "Apache Software License 2.0", "GNU General Public License v3", "Not open source"] } \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index 83c8eb5..445a667 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -1,3 +1,64 @@ # {{ cookiecutter.project_name }} -{{cookiecutter.description}} \ No newline at end of file +{{cookiecutter.description}} + +## First time setup + +Create a virtual environment and activate it. Now from the root project directory run `./scripts/bootstrap`. This will install `pip-tools` and sync any dependencies for the first time. + +To run the app you will need a [postgres] database. Create a development and a test database. Update the connection strings within the `{{cookiecutter.project_slug}}.settings.toml`. At this time, if you choose to, you can remove the demo `Todo` code and replace it with your own Model. Otherwise create your first [alembic] migration using the `alembic revision --autogenerate -m "your revision message"` command. Finally, apply your first migration with `alembic upgrade head`. + + +## Running the developement server +A `manage.py` script has been included with a collection of [click] cli functions to assist in development. + +__Note__: the developement server command is not a production webserver. You will need to c + +``` +python manage.py runserver +``` + +## Using the interactive interpreter +The `manage.py` script can be used to open an interactive interpreter with a configured molten application from your project. +``` +python manage.py shell +``` + +## Dependency management + +Dependencies are managed via [pip-tools]. + +### Adding a dependency + +To add a dependency, edit `requirements.in` (or `dev_requirements.in` +for dev dependencies) and add your dependency then run `pip-compile +requirements.in`. + +### Syncing dependencies + +Run `pip-sync requirements.txt dev_requirements.txt`. + + +## Migrations + +Migrations are managed using [alembic]. + +### Generating new migrations + + alembic revision --autogenerate -m 'message' + +### Running the migrations + + alembic upgrade head # to upgrade the local db + env ENVIRONMENT=test alembic upgrade head # to upgrade the test db + env ENVIRONMENT=prod alembic upgrade head # to upgrade prod + +## Testing + +Run the tests by invoking `py.test` in the project root. Make sure you +run any pending migrations beforehand. + +[alembic]: http://alembic.zzzcomputing.com/en/latest/ +[click]: https://click.palletsprojects.com +[pip-tools]: https://github.com/jazzband/pip-tools +[postgres]: https://www.postgresql.org/ \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/alembic.ini b/{{cookiecutter.project_slug}}/alembic.ini new file mode 100644 index 0000000..2c614a3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = migrations \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/dev_requirements.txt b/{{cookiecutter.project_slug}}/dev_requirements.txt deleted file mode 100644 index 178b283..0000000 --- a/{{cookiecutter.project_slug}}/dev_requirements.txt +++ /dev/null @@ -1,28 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file dev_requirements.txt dev_requirements.in -# ---trusted-host pypi.python.org - -appdirs==1.4.3 # via black -atomicwrites==1.2.1 # via pytest -attrs==18.2.0 # via black, pytest -black==18.9b0 -bumpversion==0.5.3 -click==7.0 # via black, pip-tools -coverage==4.5.2 # via pytest-cov -flake8==3.6.0 -mccabe==0.6.1 # via flake8 -more-itertools==4.3.0 # via pytest -pip-tools==3.1.0 -pluggy==0.8.0 # via pytest -py==1.7.0 # via pytest -pycodestyle==2.4.0 # via flake8 -pyflakes==2.0.0 # via flake8 -pytest-cov==2.6.0 -pytest==4.0.1 -six==1.11.0 # via more-itertools, pip-tools, pytest -toml==0.10.0 # via black -werkzeug==0.14.1 diff --git a/{{cookiecutter.project_slug}}/manage.py b/{{cookiecutter.project_slug}}/manage.py index fff14da..a98fe13 100644 --- a/{{cookiecutter.project_slug}}/manage.py +++ b/{{cookiecutter.project_slug}}/manage.py @@ -2,7 +2,7 @@ import click from molten.contrib.sqlalchemy import EngineData from {{cookiecutter.project_slug}}.index import create_app -app = create_app() +_, app = create_app() @click.group() @@ -47,14 +47,15 @@ def initdb(): """ Initialize database """ - from {{cookiecutter.project_slug}}.db import Base - - def _init(engine_data: EngineData): - Base.metadata.create_all(bind=engine_data.engine) - - click.echo("Creating database") - app.injector.get_resolver().resolve(_init)() - click.echo("Database created") + click.echo("This feature has been commented out. Please use alembic to manage your database initialization and changes.") + # from {{cookiecutter.project_slug}}.db import Base + # + # def _init(engine_data: EngineData): + # Base.metadata.create_all(bind=engine_data.engine) + # + # click.echo("Creating database") + # app.injector.get_resolver().resolve(_init)() + # click.echo("Database created") @cli.command() diff --git a/{{cookiecutter.project_slug}}/migrations/README b/{{cookiecutter.project_slug}}/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/{{cookiecutter.project_slug}}/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/migrations/env.py b/{{cookiecutter.project_slug}}/migrations/env.py new file mode 100644 index 0000000..6c65cd6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/migrations/env.py @@ -0,0 +1,21 @@ +"""isort:skip_file +""" +import os +import sys; sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..")) # noqa + +from alembic import context +from {{cookiecutter.project_slug}}.index import create_app +from {{cookiecutter.project_slug}}.db import Base +from molten.contrib.sqlalchemy import EngineData + +_, app = create_app() + + +def run_migrations_online(engine_data: EngineData): + with engine_data.engine.connect() as connection: + context.configure(connection=connection, target_metadata=Base.metadata) + with context.begin_transaction(): + context.run_migrations() + + +app.injector.get_resolver().resolve(run_migrations_online)() \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/migrations/script.py.mako b/{{cookiecutter.project_slug}}/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/{{cookiecutter.project_slug}}/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/{{cookiecutter.project_slug}}/requirements.in b/{{cookiecutter.project_slug}}/requirements.in index 21c792d..ff7462a 100644 --- a/{{cookiecutter.project_slug}}/requirements.in +++ b/{{cookiecutter.project_slug}}/requirements.in @@ -1,4 +1,7 @@ click molten sqlalchemy -psycopg2-binary \ No newline at end of file +psycopg2-binary +alembic +{% if cookiecutter.cors_support == 'y' %}wsgicors{% endif %} +{% if cookiecutter.static_support == 'y' %}whitenoise{% endif %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/requirements.txt b/{{cookiecutter.project_slug}}/requirements.txt deleted file mode 100644 index a3d39f3..0000000 --- a/{{cookiecutter.project_slug}}/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file requirements.txt requirements.in -# ---trusted-host pypi.python.org - -click==7.0 -molten==0.7.3 -mypy-extensions==0.4.1 # via typing-inspect -psycopg2-binary==2.7.6.1 -sqlalchemy==1.2.14 -typing-extensions==3.6.6 # via molten -typing-inspect==0.3.1 # via molten diff --git a/{{cookiecutter.project_slug}}/scripts/bootstrap b/{{cookiecutter.project_slug}}/scripts/bootstrap new file mode 100755 index 0000000..38e8a67 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/bootstrap @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# setting -e to exit immediately on a command failure. +# setting -o pipefail sets the exit code of a pipeline to that of the rightmost command to exit with a non-zero status, or to zero if all commands of the pipeline exit successfully. +set -eo pipefail + +if [ -z "$VIRTUAL_ENV" ]; then + echo "warning: you are not in a virtualenv" + exit 1 +fi + +pip install -U pip pip-tools +pip-compile requirements.in +pip-compile dev_requirements.in +pip-sync requirements.txt dev_requirements.txt diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py index 495394e..5bba6d3 100644 --- a/{{cookiecutter.project_slug}}/tests/conftest.py +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -1,42 +1,51 @@ import pytest from molten import testing -from molten.contrib.sqlalchemy import EngineData +from molten.contrib.sqlalchemy import Session from {{cookiecutter.project_slug}}.index import create_app -from {{cookiecutter.project_slug}}.db import Base +def truncate_all_tables(session: Session): + table_names = session.execute(""" + select table_name from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + and table_name != 'alembic_version' + """) + for (table_name,) in table_names: + # "truncate" can deadlock so we use delete which is guaranteed not to. + session.execute(f"delete from {table_name}") + session.commit() -# requires function scope so that database is removed on every tests -@pytest.fixture(scope="function") -def app(): - app = create_app() - yield app - - -@pytest.fixture(autouse=True) -def create_db(app): - """Creates a test database with session scope""" - def _retrieve_engine(engine_data: EngineData): - return engine_data.engine - engine = app.injector.get_resolver().resolve(_retrieve_engine)() - - Base.metadata.create_all(bind=engine) +@pytest.fixture(scope="session") +def app_global(): + _, app = create_app() + yield app - yield - Base.metadata.drop_all(bind=engine) +@pytest.fixture +def app(app_global): + # This is a little "clever"/piggy. We only want a single instance + # of the app to ever be created, but we also want to ensure that + # the DB is cleared after every test hence "app_global" being a + # session-scoped fixture and this one being test-scoped. + yield app_global + resolver = app_global.injector.get_resolver() + resolver.resolve(truncate_all_tables)() -@pytest.fixture(scope="function") +@pytest.fixture def client(app): """Creates a testing client""" return testing.TestClient(app) - -@pytest.fixture(scope="function") -def session(): - pass \ No newline at end of file +@pytest.fixture +def load_component(app): + def load(annotation): + def loader(c: annotation): + return c + return app.injector.get_resolver().resolve(loader)() + return load diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py index 1e8ee94..f64c33b 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/index.py @@ -4,12 +4,19 @@ from molten.http import HTTP_404, Request from molten.openapi import Metadata, OpenAPIHandler, OpenAPIUIHandler from molten.settings import SettingsComponent from molten.contrib.sqlalchemy import SQLAlchemyMiddleware, SQLAlchemyEngineComponent, SQLAlchemySessionComponent +{%- if cookiecutter.cors_support == 'y' %} +from wsgicors import CORS +{%- endif %} +{%- if cookiecutter.static_support == 'y' %} +from whitenoise import WhiteNoise +{%- endif %} from .api.welcome import welcome from .api.todo import TodoManagerComponent, todo_routes from .common import ExtJSONRenderer +from .logging import setup_logging from .schema import APIResponse -from .settings import SETTINGS +from . import settings get_schema = OpenAPIHandler( metadata=Metadata( @@ -22,7 +29,7 @@ get_schema = OpenAPIHandler( get_docs = OpenAPIUIHandler() components = [ - SettingsComponent(SETTINGS), + SettingsComponent(settings), SQLAlchemyEngineComponent(), SQLAlchemySessionComponent(), TodoManagerComponent(), @@ -63,12 +70,23 @@ class ExtApp(App): def create_app(_components=None, _middleware=None, _routes=None, _renderers=None): """ - Factory function for the creation of a `molten.App` instance + Factory function for the creation of a `molten.App`. """ - app = ExtApp( + setup_logging() + + wrapped_app = app = ExtApp( components=_components or components, middleware=_middleware or middleware, routes=_routes or routes, renderers=_renderers or renderers ) - return app + + {%- if cookiecutter.cors_support == 'y' %} + wrapped_app = CORS(wrapped_app, **settings.strict_get("wsgicors")) + {%- endif %} + + {%- if cookiecutter.static_support == 'y' %} + wrapped_app = WhiteNoise(wrapped_app, **settings.strict_get("whitenoise")) + {%- endif %} + + return wrapped_app, app diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/logging.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/logging.py new file mode 100644 index 0000000..fb36de8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/logging.py @@ -0,0 +1,29 @@ +import logging.config +import sys + +FORMAT = "[%(asctime)s] [PID %(process)d] [%(threadName)s] [%(request_id)s] [%(name)s] [%(levelname)s] %(message)s" # noqa + + +def setup_logging(): + logging.config.dictConfig( + { + "disable_existing_loggers": False, + "version": 1, + "filters": { + "request_id": {"()": "molten.contrib.request_id.RequestIdFilter"} + }, + "formatters": {"console": {"format": FORMAT}}, + "handlers": { + "default": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "stream": sys.stderr, + "formatter": "console", + "filters": ["request_id"], + } + }, + "loggers": { + "": {"handlers": ["default"], "level": "DEBUG", "propagate": False} + }, + } + ) diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/settings.toml b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/settings.toml index e42cc8e..01723ff 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/settings.toml +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/settings.toml @@ -1,10 +1,26 @@ [common] database_engine_dsn = "postgresql://molten:local@localhost/cookiecutter" +[common.wsgicors] +headers="*" +methods="*" +maxage="180" +origin="*" + +[common.whitenoise] +root = "static" +prefix = "/static" +autorefresh = true + [dev] database_engine_params.echo = true database_engine_params.connect_args.options = "-c timezone=utc" [test] database_engine_dsn = "postgresql://molten:local@localhost/test_cookiecutter" -database_engine_params.echo = true \ No newline at end of file +database_engine_params.echo = true + +[prod.whitenoise] +root = "static" +prefix = "/static" +autorefresh = false \ No newline at end of file