diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4716794 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,htmlcov,.cache,.tox,env,dist,doc,*egg,build \ No newline at end of file diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..68cf408 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,5 @@ +#History + +0.2.0 Level up to Apistar 0.5.x + +0.1.0 Initial app \ No newline at end of file diff --git a/README.md b/README.md index ee84ad7..964504d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # news -A ReSTful api for storing and retrieving news articles \ No newline at end of file +A ReSTful api for storing and retrieving news articles + +## Pip tools +This project uses pip-tools for developement. Reference the [project page](https://github.com/jazzband/pip-tools) for usage instructions. \ No newline at end of file diff --git a/dev_requirements.in b/dev_requirements.in new file mode 100644 index 0000000..4c8ad1e --- /dev/null +++ b/dev_requirements.in @@ -0,0 +1,6 @@ +pip-tools +pytest +tox +flake8 +setuptools >= 38.6.0 +wheel >= 0.31.0 \ No newline at end of file diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..7dc9f6b --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,29 @@ +# +# 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 +--trusted-host git.aigalatic.com + +atomicwrites==1.1.5 # via pytest +attrs==18.1.0 # via pytest +click==6.7 # via pip-tools +first==2.0.1 # via pip-tools +flake8==3.5.0 +mccabe==0.6.1 # via flake8 +more-itertools==4.2.0 # via pytest +pip-tools==2.0.2 +pluggy==0.6.0 # via pytest, tox +py==1.5.4 # via pytest, tox +pycodestyle==2.3.1 # via flake8 +pyflakes==1.6.0 # via flake8 +pytest==3.6.2 +six==1.11.0 # via more-itertools, pip-tools, pytest, tox +tox==3.0.0 +virtualenv==16.0.0 # via tox +wheel==0.31.1 + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/news/__init__.py b/news/__init__.py index 526ecad..44a0cc8 100644 --- a/news/__init__.py +++ b/news/__init__.py @@ -1,2 +1 @@ -from .app import application_factory - +from .app import application_factory # noqa: F401 diff --git a/news/app.py b/news/app.py index 282d2bd..fbc05ae 100644 --- a/news/app.py +++ b/news/app.py @@ -1,13 +1,8 @@ -from apistar import Include, Route, http -from apistar.interfaces import Router -from apistar.frameworks.wsgi import WSGIApp as App -from apistar.backends import sqlalchemy_backend -from apistar.backends.sqlalchemy_backend import Session -from apistar.handlers import docs_urls, static_urls - -from .models import Base, NewsArticle, NewsSource -from .renders import JSONRenderer +from apistar import Route, http, App +from sqlalchemy import create_engine +from .models import NewsArticle, NewsSource from .schema import NewsSourceSchema, NewsArticleSchema +from .util import Session, SQLAlchemySession, SQLAlchemyHook news_source_schema = NewsSourceSchema() news_article_schema = NewsArticleSchema() @@ -15,22 +10,22 @@ news_article_schema = NewsArticleSchema() def get_sources(session: Session): sources = session.query(NewsSource).all() - return http.Response(news_source_schema.dump(sources, many=True).data, status=200) + return http.JSONResponse(news_source_schema.dump(sources, many=True).data, status_code=200) -def add_source(session: Session, request_data: http.RequestData, router: Router): +def add_source(session: Session, request_data: http.RequestData, app: App): news_source_data, errors = news_source_schema.load(request_data) if errors: msg = {"message": "400 Bad Request", "error": errors} - return http.Response(msg, status=400) + return http.JSONResponse(msg, status_code=400) news_source = NewsSource(**news_source_data) session.add(news_source) session.flush() - headers = {"Location": router.reverse_url('get_source', {"id": news_source.id})} + headers = {"Location": app.reverse_url('get_source', {"id": news_source.id})} - return http.Response(news_source_schema.dump(news_source).data, status=201, headers=headers) + return http.JSONResponse(news_source_schema.dump(news_source).data, status_code=201, headers=headers) def delete_source(session: Session, id: int): @@ -38,47 +33,47 @@ def delete_source(session: Session, id: int): news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) + return http.JSONResponse(msg, status_code=404) msg = {"message": "200 OK"} - return http.Response(msg, status=200) + return http.JSONResponse(msg, status_code=200) def get_source(session: Session, id: int): news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) + return http.JSONResponse(msg, status_code=404) - return http.Response(news_source_schema.dump(news_source).data, status=200) + return http.JSONResponse(news_source_schema.dump(news_source).data, status_code=200) def get_articles(session: Session): articles = session.query(NewsArticle).all() - return http.Response(news_article_schema.dump(articles, many=True).data, status=200) + return http.JSONResponse(news_article_schema.dump(articles, many=True).data, status_code=200) -def add_article(session: Session, request_data: http.RequestData, router: Router): +def add_article(session: Session, request_data: http.RequestData, app: App): news_article_data, errors = news_article_schema.load(request_data) if errors: msg = {"message": "400 Bad Request", "error": errors} - return http.Response(msg, status=400) + return http.JSONResponse(msg, status_code=400) news_article = NewsArticle(**news_article_data) session.add(news_article) session.flush - headers = {"Location": router.reverse_url('get_source', {"id": news_article.id})} + headers = {"Location": app.reverse_url('get_source', {"id": news_article.id})} - return http.Response(news_article_schema.dump(news_article), status=201, headers=headers) + return http.JSONResponse(news_article_schema.dump(news_article), status_code=201, headers=headers) def get_article(session: Session, id: int): news_article = session.query(NewsArticle).filter_by(id=id).one_or_none() if news_article is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) - return http.Response(news_article_schema.dump(news_article), status=200) + return http.JSONResponse(msg, status_code=404) + return http.JSONResponse(news_article_schema.dump(news_article), status_code=200) def delete_article(session: Session, id): @@ -86,10 +81,10 @@ def delete_article(session: Session, id): news_article = session.query(NewsArticle).filter_by(id=id).one_or_none() if news_article is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) + return http.JSONResponse(msg, status_code=404) msg = {"message": "200 OK"} - return http.Response(msg, status=200) + return http.JSONResponse(msg, status_code=200) routes = [ @@ -101,29 +96,22 @@ routes = [ Route('/articles', 'POST', add_article), Route('/articles/{id}', 'GET', get_article), Route('/articles/{id}', 'DELETE', delete_article), - Include('/docs', docs_urls), - Include('/static', static_urls) ] -_settings = { - 'DATABASE': { - 'URL': 'postgresql://apistar:local@localhost/news', - 'METADATA': Base.metadata - }, - 'RENDERERS': [JSONRenderer()] -} - routes = routes -commands = sqlalchemy_backend.commands +components = [SQLAlchemySession(engine=create_engine('postgresql://apistar:local@localhost/news'))] -components = sqlalchemy_backend.components +hooks = [SQLAlchemyHook()] -def application_factory(settings={}): +def application_factory(): """Returns an instance of Cookie API""" - app_settings = {**_settings, **settings} - return App(settings=app_settings, - commands=commands, - components=components, + return App(components=components, + event_hooks=hooks, routes=routes) + + +if __name__ == "__main__": + app = application_factory() + app.serve('0.0.0.0', 8080, debug=True) diff --git a/news/models.py b/news/models.py index 3dec3fa..0cc35b4 100644 --- a/news/models.py +++ b/news/models.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Numeric, BigInteger, Text, Table, ARRAY +from sqlalchemy import Column, ForeignKey, DateTime, BigInteger, Text, Table, ARRAY from sqlalchemy.orm import relationship, backref -from sqlalchemy.sql import expression, and_ +from sqlalchemy.sql import expression from sqlalchemy.ext.compiler import compiles from sqlalchemy.types import DateTime as DateTimeType from sqlalchemy.orm.exc import NoResultFound diff --git a/news/renders.py b/news/renders.py deleted file mode 100644 index 59bb97f..0000000 --- a/news/renders.py +++ /dev/null @@ -1,23 +0,0 @@ -import datetime as dt -import decimal -import json - -from apistar import http -from apistar.renderers import Renderer - - -def extended_encoder(obj): - """JSON encoder function with support for ISO 8601 datetime serialization and Decimal to float casting""" - if isinstance(obj, dt.datetime): - return obj.timestamp() - elif isinstance(obj, decimal.Decimal): - return float(obj) - - -class JSONRenderer(Renderer): - """JSON Render with support for ISO 8601 datetime serialization and Decimal to float casting""" - media_type = 'application/json' - charset = None - - def render(self, data: http.ResponseData) -> bytes: - return json.dumps(data, default=extended_encoder).encode('utf-8') diff --git a/news/schema.py b/news/schema.py index 5031c39..fd1b3f3 100644 --- a/news/schema.py +++ b/news/schema.py @@ -5,6 +5,7 @@ from marshmallow import Schema, fields class PassDateTime(fields.DateTime): """Used to Pass Datetime deserialization """ + def _deserialize(self, value, attr, data): if isinstance(value, dt.datetime): return value @@ -40,14 +41,17 @@ class NewsSourceSchema(Schema): created_date = UnixTimestamp(dump_only=True) modified_date = UnixTimestamp(dump_only=True) url = fields.URL() - source_name = fields.Str(required=True, error_messages={'required': 'NewsSource name is a required field'}) - source_type = fields.Str(required=True, error_messages={'required': 'NewsSource type is a required field'}) + source_name = fields.Str(required=True, + error_messages={'required': 'NewsSource name is a required field'}) + source_type = fields.Str(required=True, + error_messages={'required': 'NewsSource type is a required field'}) # TODO add support for Categories class Meta: ordered = True + # TODO deserialization of timestamp to class NewsArticleSchema(Schema): id = fields.Int(dump_only=True) @@ -61,7 +65,8 @@ class NewsArticleSchema(Schema): authors = fields.List(fields.Str()) # published_date = fields.DateTime() published_date = PassDateTime() - news_blob = fields.Str(required=True, error_messages={'required': 'NewsArticle must include news content'}) + news_blob = fields.Str(required=True, + error_messages={'required': 'NewsArticle must include news content'}) # TODO add support for Tags diff --git a/news/util.py b/news/util.py new file mode 100644 index 0000000..7292a9d --- /dev/null +++ b/news/util.py @@ -0,0 +1,38 @@ +import inspect +import typing +from apistar import Component +from apistar.http import Response +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker, scoped_session + +DBSession = scoped_session(sessionmaker()) + +Session = typing.TypeVar("Session") + + +class SQLAlchemySession(Component): + def __init__(self, engine=None): + if not isinstance(engine, Engine): + raise ValueError('SQLAlchemySession must be instantiated with a sqlalchemy.engine.Engine object') + self.engine = engine + DBSession.configure(bind=self.engine) + + def resolve(self): + return DBSession() + + def can_handle_parameter(self, parameter: inspect.Parameter) -> bool: + return parameter.annotation is Session + + +class SQLAlchemyHook: + def on_request(self, session: Session): + return + + def on_response(self, session: Session, response: Response): + DBSession.remove() + return response + + def on_error(self, session: Session, response: Response): + session.rollback() + DBSession.remove() + return response diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..2adda50 --- /dev/null +++ b/requirements.in @@ -0,0 +1,5 @@ +apistar +psycopg2 +marshmallow +SQLAlchemy + diff --git a/requirements.txt b/requirements.txt index 7ddafb4..a631118 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,24 @@ -apistar==0.3.9 -certifi==2017.11.5 -chardet==3.0.4 -colorama==0.3.9 -coreapi==2.3.3 -coreschema==0.0.4 -idna==2.6 -itypes==1.1.0 -Jinja2==2.9.6 -MarkupSafe==1.0 -marshmallow==2.15.0 -psycopg2==2.7.3.2 -py==1.4.34 -pytest==3.2.3 -requests==2.18.4 -SQLAlchemy==1.1.15 -uritemplate==3.0.0 -urllib3==1.22 -Werkzeug==0.12.2 -whitenoise==3.3.1 +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file requirements.txt requirements.in +# +--trusted-host pypi.python.org +--trusted-host git.aigalatic.com + +apistar==0.5.40 +certifi==2018.4.16 # via requests +chardet==3.0.4 # via requests +click==6.7 # via apistar +idna==2.7 # via requests +jinja2==2.10 # via apistar +markupsafe==1.0 # via jinja2 +marshmallow==2.15.3 +psycopg2==2.7.5 +pyyaml==3.12 # via apistar +requests==2.19.1 # via apistar +sqlalchemy==1.2.9 +urllib3==1.23 # via requests +werkzeug==0.14.1 # via apistar +whitenoise==3.3.1 # via apistar diff --git a/scripts/wsgi_app.py b/scripts/wsgi_app.py new file mode 100644 index 0000000..1eb5c34 --- /dev/null +++ b/scripts/wsgi_app.py @@ -0,0 +1,11 @@ +from news import application_factory + +app = application_factory() + + +def main(): + app.serve('0.0.0.0', 8080, debug=True) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6b00d72 --- /dev/null +++ b/setup.py @@ -0,0 +1,79 @@ +from setuptools import setup, find_packages + +with open('README.md') as readme_file: + readme = readme_file.read() + +with open('HISTORY.md') as history_file: + history = history_file.read() + +requirements = [ + 'apistar==0.5.40', + 'certifi==2018.4.16', # via requests + 'chardet==3.0.4', # via requests + 'click==6.7', # via apistar + 'idna==2.7', # via requests + 'jinja2==2.10', # via apistar + 'markupsafe==1.0', # via jinja2 + 'marshmallow==2.15.3', + 'psycopg2==2.7.5', + 'pyyaml==3.12', # via apistar + 'requests==2.19.1', # via apistar + 'sqlalchemy==1.2.9', + 'urllib3==1.23', # via requests + 'werkzeug==0.14.1', # via apistar + 'whitenoise==3.3.1', # via apistar +] + +dev_requirements = [ + 'atomicwrites==1.1.5', # via pytest + 'attrs==18.1.0', # via pytest + 'click==6.7', # via pip-tools + 'first==2.0.1', # via pip-tools + 'flake8==3.5.0', + 'mccabe==0.6.1', # via flake8 + 'more-itertools==4.2.0', # via pytest + 'pip-tools==2.0.2', + 'pluggy==0.6.0', # via pytest, tox + 'py==1.5.4', # via pytest, tox + 'pycodestyle==2.3.1', # via flake8 + 'pyflakes==1.6.0', # via flake8 + 'pytest==3.6.2', + 'six==1.11.0', # via more-itertools, pip-tools, pytest, tox + 'tox==3.0.0', + 'virtualenv==16.0.0', # via tox + 'wheel==0.31.1', +] + +setup( + name='news', + version='0.2.0', + description="A ReSTful api for storing and retrieving news articles", + long_description=readme + '\n\n' + history, + long_description_content_type='text/markdown', + author="Drew Bednar", + author_email='drew@androiddrew.com', + url='https://git.aigalactic.com/Spyglass/news', + packages=find_packages(include=['news', 'scripts']), + include_package_data=True, + install_requires=requirements, + license='BSD', + keywords='news spyglass', + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Environment :: Backend', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + extras_require={ + 'dev': dev_requirements, + }, + entry_points={ + 'console_scripts': [ + 'wsgi_serve=scripts.wsgi_app:main' + ] + }, + +) diff --git a/tests/test_renders.py b/tests/test_renders.py deleted file mode 100644 index 5b9c274..0000000 --- a/tests/test_renders.py +++ /dev/null @@ -1,23 +0,0 @@ -import datetime as dt -from decimal import Decimal -import json - -from news.renders import extended_encoder, JSONRenderer - - -def test_extended_encoder_date_parsing(): - test_date = dt.datetime(2017, 5, 10) - assert test_date.timestamp() == extended_encoder(test_date) - - -def test_extended_encoder_decimal_casting(): - test_decimal = Decimal('1.0') - assert 1.0 == extended_encoder(test_decimal) - - -def test_render_with_extended_encoder(): - test_date = dt.datetime(2017, 5, 10) - test_decimal = Decimal('0.1') - expected = dict(my_date=1494388800.0, my_float=0.1) - test_response = dict(my_date=test_date, my_float=test_decimal) - assert json.dumps(expected).encode('utf-8') == JSONRenderer().render(test_response) diff --git a/wsgi_app.py b/wsgi_app.py deleted file mode 100644 index 92f1090..0000000 --- a/wsgi_app.py +++ /dev/null @@ -1,9 +0,0 @@ -from news import application_factory - -# Override base application settings here -settings = {} - -app = application_factory(settings) - -if __name__ == "__main__": - app.main() \ No newline at end of file