Inital commit

master
androiddrew 5 years ago
parent 581dd74b4e
commit 020478cebb

@ -0,0 +1 @@
PHONEY

@ -0,0 +1,5 @@
black
flake8
pytest
pytest-cov
pip-tools

@ -0,0 +1,32 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile dev_requirements.in
#
--trusted-host pypi.python.org
appdirs==1.4.3 # via black
atomicwrites==1.3.0 # via pytest
attrs==19.1.0 # via black, pytest
black==19.3b0
click==7.0 # via black, pip-tools
coverage==4.5.4 # via pytest-cov
entrypoints==0.3 # via flake8
flake8==3.7.8
importlib-metadata==0.23 # via pluggy, pytest
mccabe==0.6.1 # via flake8
more-itertools==7.2.0 # via pytest, zipp
packaging==19.2 # via pytest
pip-tools==4.1.0
pluggy==0.13.0 # via pytest
py==1.8.0 # via pytest
pycodestyle==2.5.0 # via flake8
pyflakes==2.1.1 # via flake8
pyparsing==2.4.2 # via packaging
pytest-cov==2.7.1
pytest==5.2.0
six==1.12.0 # via packaging, pip-tools
toml==0.10.0 # via black
wcwidth==0.1.7 # via pytest
zipp==0.6.0 # via importlib-metadata

@ -0,0 +1,26 @@
# Gear Sockets
A websocket service for fo use in the gears application.
# Development
# Testing
## Websocket testing
The easiest way to test the websocket components is within the developer tools of the browser.
```javascript
ws = new WebSocket("ws://localhost:8010/ws"); // create new connection
// Log messages from the server
ws.onmessage = function (e) {
console.log('From Server: ' + e.data);
};
// Sending a String
ws.send('your message');
>>From Server: Message text was: your message
```

@ -0,0 +1,22 @@
from starlette.applications import Starlette
from starlette.responses import JSONResponse
import uvicorn
app = Starlette(debug=True)
@app.route('/')
async def index(request):
return JSONResponse({"Hello": "Dirp"})
@app.websocket_route('/ws')
async def websocket_endpoint(websocket):
await websocket.accept()
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Message text was: {data}")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8010)

@ -0,0 +1,2 @@
starlette[requests]
uvicorn

@ -0,0 +1,15 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile requirements.in
#
--trusted-host pypi.python.org
click==7.0 # via uvicorn
h11==0.8.1 # via uvicorn
httptools==0.0.13 # via uvicorn
starlette[requests]==0.12.9
uvicorn==0.9.0
uvloop==0.13.0 # via uvicorn
websockets==8.0.2 # via uvicorn

@ -0,0 +1,6 @@
[run]
source = gears_api
omit = test*
[report]
show_missing = True

@ -0,0 +1,62 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Pycharm
.idea

@ -0,0 +1,10 @@
MIT License
Copyright (c) 2019, Drew
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -0,0 +1,64 @@
# gears_api
API for the gears application
## 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 `gears_api.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/

@ -0,0 +1,2 @@
[alembic]
script_location = migrations

@ -0,0 +1,7 @@
black
bumpversion
flake8
pip-tools
pytest
pytest-cov
werkzeug

@ -0,0 +1,5 @@
from typing import Dict
def welcome() -> Dict:
return {"message": "welcome to gears_api"}

@ -0,0 +1,31 @@
import datetime as dt
from os import path
from decimal import Decimal
from typing import Any
from molten import JSONRenderer, is_schema, dump_schema
BASE_PATH = path.normpath(path.join(path.abspath(path.dirname(__file__)), "."))
def path_to(*xs):
"""
Construct a path from the root project directory
"""
return path.join(BASE_PATH, *xs)
class ExtJSONRenderer(JSONRenderer):
"""JSON Render with support for ISO 8601 datetime format strings and Decimal"""
def default(self, ob: Any) -> Any:
"""You may override this when subclassing the JSON renderer in
order to encode non-standard object types.
"""
if is_schema(type(ob)):
return dump_schema(ob)
if isinstance(ob, dt.datetime):
return ob.isoformat()
if isinstance(ob, Decimal):
return float(ob)
raise TypeError(f"cannot encode values of type {type(ob)}") # pragma: no cover

@ -0,0 +1,32 @@
from sqlalchemy import Column, BigInteger, DateTime
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import CreateColumn
from sqlalchemy.sql import expression
from sqlalchemy.types import DateTime as DatetimeType
Base = declarative_base()
class utcnow(expression.FunctionElement):
type = DatetimeType()
@compiles(utcnow, "postgresql")
def pg_utcnow(element, compiler, **kw):
return "TIMEZONE('utc', CURRENT_TIMESTAMP)"
@compiles(CreateColumn, 'postgresql')
def use_identity(element, compiler, **kw):
text = compiler.visit_create_column(element, **kw)
text = text.replace("SERIAL", "INT GENERATED BY DEFAULT AS IDENTITY")
return text
class DBMixin:
id = Column(BigInteger, primary_key=True)
created_date = Column(DateTime(timezone=True), server_default=utcnow())
modified_date = Column(
DateTime(timezone=True), server_default=utcnow(), onupdate=utcnow()
)

@ -0,0 +1,5 @@
from molten.errors import MoltenError
class EntityNotFound(MoltenError):
"""Raised when an entity is not found using an `exists` check in sqlalchemy."""

@ -0,0 +1,81 @@
from typing import Tuple
from molten import App, Route, ResponseRendererMiddleware, Settings
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
from wsgicors import CORS
from whitenoise import WhiteNoise
from .api.welcome import welcome
from .common import ExtJSONRenderer
from .logger import setup_logging
from .schema import APIResponse
from . import settings
get_schema = OpenAPIHandler(
metadata=Metadata(
title="gears_api",
description="API for the gears application",
version="0.0.0"
)
)
get_docs = OpenAPIUIHandler()
components = [
SettingsComponent(settings),
SQLAlchemyEngineComponent(),
SQLAlchemySessionComponent(),
]
middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()]
renderers = [ExtJSONRenderer()]
routes = [
Route("/", welcome, "GET"),
Route("/_schema", get_schema, "GET"),
Route("/_docs", get_docs, "GET"),
]
class ExtApp(App):
def handle_404(self, request: Request) -> Tuple[str, APIResponse]:
"""
Returns as standardized JSONResponse on HTTP 404 Error.
"""
return (
HTTP_404,
APIResponse(
status=404,
message=f"The resource you are looking for {request.scheme}://{request.host}{request.path} doesn't exist",
),
)
@property
def settings(self):
def _get_settings(_settings: Settings):
return _settings
settings = self.injector.get_resolver().resolve(_get_settings)()
return settings
def create_app(_components=None, _middleware=None, _routes=None, _renderers=None):
"""
Factory function for the creation of a `molten.App`.
"""
setup_logging()
wrapped_app = app = ExtApp(
components=_components or components,
middleware=_middleware or middleware,
routes=_routes or routes,
renderers=_renderers or renderers
)
wrapped_app = CORS(wrapped_app, **settings.strict_get("wsgicors"))
wrapped_app = WhiteNoise(wrapped_app, **settings.strict_get("whitenoise"))
return wrapped_app, app

@ -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}
},
}
)

@ -0,0 +1,32 @@
from abc import ABCMeta, abstractmethod
from molten import BaseApp, HTTPError, HTTP_409, HTTP_404
from sqlalchemy.orm import Session
class BaseManager(metaclass=ABCMeta):
"""Base instance for Model managers"""
def __init__(self, session: Session, app: BaseApp):
self.session = session
self.app = app
@abstractmethod
def model_from_schema(self, schema):
"""Converts a Schema instance into a SQLAlchemy ORM model instance"""
pass
@abstractmethod
def schema_from_model(self, result):
"""Converts a SQLAlchemy results proxy into a Schema instance"""
pass
def raise_409(self, id:int):
"""Raises a 409 HTTP error response in the event of Conflict"""
raise HTTPError(
HTTP_409,
{
"status": 409,
"message": f"Entity {self.__class__.__name__} with id: {id} already exists"
}
)

@ -0,0 +1,14 @@
from molten import schema, field
@schema
class Link:
href: str
@schema
class APIResponse:
status: int = field(description="An HTTP status code")
message: str = field(
description="A user presentable message in response to the request provided to the API"
)

@ -0,0 +1,12 @@
import os
from molten.contrib.toml_settings import TOMLSettings
from .common import path_to
ENVIRONMENT = os.getenv("ENVIRONMENT", "dev")
SETTINGS = TOMLSettings.from_path(path_to("settings.toml"), ENVIRONMENT)
def __getattr__(name):
return getattr(SETTINGS, name)

@ -0,0 +1,26 @@
[common]
database_engine_dsn = "$GEARS_DB_DSN"
[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
[prod.whitenoise]
root = "static"
prefix = "/static"
autorefresh = false

@ -0,0 +1,82 @@
import click
from molten.contrib.sqlalchemy import EngineData
from gears_api.index import create_app
_, app = create_app()
@click.group()
def cli():
pass
@cli.command()
@click.option("--host", "-h", default="0.0.0.0", help="A hostname or IP address")
@click.option(
"--port", "-p", default=8000, help="Port number to bind to development server"
)
def runserver(host, port):
"""
Runs a Werkzueg development server. Do no use for production.
"""
from werkzeug.serving import run_simple
run_simple(
hostname=host, port=port, application=app, use_debugger=True, use_reloader=True
)
@cli.command()
def shell():
"""
Enters an interactive shell with an app instance and dependency resolver.
"""
import rlcompleter
import readline
from code import InteractiveConsole
helpers = {"app": app, "resolver": app.injector.get_resolver()}
readline.parse_and_bind("tab: complete")
interpreter = InteractiveConsole(helpers)
interpreter.interact(f"Instances in scope: {', '.join(helpers)}.", "")
@cli.command()
def initdb():
"""
Initialize database
"""
click.echo("This feature has been commented out. Please use alembic to manage your database initialization and changes.")
# from gears_api.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()
def dropdb():
"""
Drop all tables in database
"""
from gears_api.db import Base
def _drop(engine_data: EngineData):
Base.metadata.drop_all(bind=engine_data.engine)
click.echo("Are you sure you would like to drop the database?: [Y/N]")
response = input()
if response.lower() == "y":
app.injector.get_resolver().resolve(_drop)()
click.echo("Database dropped")
else:
click.echo("Database drop aborted")
if __name__ == "__main__":
cli()

@ -0,0 +1,2 @@
Generic single-database configuration.

@ -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 gears_api.index import create_app
from gears_api.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)()

@ -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"}

@ -0,0 +1,28 @@
"""Initial commit
Revision ID: dc04b852fef5
Revises:
Create Date: 2019-10-06 21:57:51.496879
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dc04b852fef5'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

@ -0,0 +1,9 @@
alembic
click
gunicorn
molten
molten-jwt
psycopg2-binary
sqlalchemy
whitenoise
wsgicors

@ -0,0 +1,24 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile gears_api/requirements.in
#
--trusted-host pypi.python.org
alembic==1.2.1
click==7.0
gunicorn==19.9.0
mako==1.1.0 # via alembic
markupsafe==1.1.1 # via mako
molten==0.7.4
mypy-extensions==0.4.2 # via typing-inspect
psycopg2-binary==2.8.3
python-dateutil==2.8.0 # via alembic
python-editor==1.0.4 # via alembic
six==1.12.0 # via python-dateutil
sqlalchemy==1.3.9
typing-extensions==3.7.4 # via molten
typing-inspect==0.3.1 # via molten
whitenoise==4.1.4
wsgicors==0.7.0

@ -0,0 +1 @@
export GEARS_DB_DSN='postgresql://gears@localhost/gears'

@ -0,0 +1,15 @@
from setuptools import setup, find_packages
setup(
name="gears_api",
version="0.1.0",
author="Drew",
author_email="drew@runcible.io",
description="API for the gears application",
packages=find_packages(exclude=["tests"]),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT license",
"Operating System :: OS Independent",
],
)

@ -0,0 +1,51 @@
import pytest
from molten import testing
from molten.contrib.sqlalchemy import Session
from gears_api.index import create_app
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()
@pytest.fixture(scope="session")
def app_global():
_, app = create_app()
yield app
@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
def client(app):
"""Creates a testing client"""
return testing.TestClient(app)
@pytest.fixture
def load_component(app):
def load(annotation):
def loader(c: annotation):
return c
return app.injector.get_resolver().resolve(loader)()
return load

@ -0,0 +1,55 @@
def test_welcome_route(client):
message = "welcome to gears_api"
response = client.get("/")
content = response.json()
assert message == content.get("message")
def test_empty_get_todos(client):
response = client.get("/todos")
assert response.status_code == 200
assert response.json() == []
def test_insert_todo(client):
payload = {"todo": "walk the dog"}
response = client.post("/todos", data=payload)
content = response.json()
assert response.status_code == 201
assert type(content['id']) == int
assert content['todo'] == payload['todo']
def test_get_individual_todo_by_href(client):
payload = {"todo": "my individual todo"}
response = client.post("/todos", data=payload)
content = response.json()
get_response = client.get(f"{content.get('href')}")
get_content = get_response.json()
assert get_response.status_code == 200
assert content == get_content
def test_update_todo(client):
payload = {"todo": "sample app"}
response = client.post("/todos", json=payload)
todo = response.json()
update_response = client.patch("{}".format(todo.get("href")), json={"complete": True, "todo": "sample app"})
updated_todo = update_response.json()
assert updated_todo["complete"] == True
def test_todo_not_found(client):
response = client.get("/todo/1111111")
content = response.json()
assert response.status_code == 404
assert content["status"] == 404
assert content["message"]
def test_delete_todo(client):
payload = {"todo": "sample app"}
response = client.post("/todos", json=payload)
todo = response.json()
delete_response = client.delete(f"/todos/{todo.get('id')}")
assert delete_response.status_code == 202

@ -0,0 +1,15 @@
import datetime as dt
from decimal import Decimal
from gears_api.common import ExtJSONRenderer
def test_extended_encoder_date_parsing():
json_renderer = ExtJSONRenderer()
test_date = dt.datetime(2017, 5, 10)
assert test_date.isoformat() == json_renderer.default(test_date)
def test_extended_encoder_decimal_casting():
json_renderer = ExtJSONRenderer()
test_decimal = Decimal('1.0')
assert 1.0 == json_renderer.default(test_decimal)
Loading…
Cancel
Save