Initial commit

master
androiddrew 6 years ago
commit f9c9146190

73
.gitignore vendored

@ -0,0 +1,73 @@
# virtualenv
env
# Pycharm
.idea
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
pypyenv/
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
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# Redis
/dump.rdb
# Project
config.py
# Mac
.DS_Store

@ -0,0 +1,16 @@
# Market Look
A application of tracking in store promotion compliance at a store.
## API
This api returns resource representations with `Content Type` of `application/json`. No other mime types are supported by this api at this time.
### Date times
When a date time is provided with a representation it will follow the ISO 8601 extend format for easy human readability.
### Features
- [ ] Resource expansion.
- [ ] JWT Authentication

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

@ -0,0 +1,25 @@
#
# 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
appdirs==1.4.3 # via black
atomicwrites==1.1.5 # via pytest
attrs==18.1.0 # via black, pytest
black==18.6b4
click==6.7 # via black, pip-tools
coverage==4.5.1 # via pytest-cov
first==2.0.1 # via pip-tools
more-itertools==4.3.0 # via pytest
pip-tools==2.0.2
pluggy==0.7.1 # via pytest
py==1.5.4 # via pytest
pytest-cov==2.5.1
pytest==3.7.1
six==1.11.0 # via more-itertools, pip-tools, pytest
toml==0.9.4 # via black
werkzeug==0.14.1

@ -0,0 +1,230 @@
import os
from typing import List, Tuple, Any, Dict, Optional
from molten import (
annotate,
App,
Request,
QueryParam,
Route,
Settings,
SettingsComponent,
ResponseRendererMiddleware,
HTTP_201,
HTTP_202,
HTTP_404,
HTTPError,
)
from molten.openapi import Metadata, OpenAPIHandler, OpenAPIUIHandler
from molten.contrib.sqlalchemy import (
SQLAlchemyEngineComponent,
SQLAlchemySessionComponent,
SQLAlchemyMiddleware,
)
from .errors import EntityNotFound
from .manager import (
AlertManager,
StoreManager,
GeographyManager,
AlertManagerComponent,
StoreManagerComponent,
GeographyManagerComponent,
)
from .schema import Alert, Store, Geography, APIResponse
from .util import ExtJSONRender
class MarketLook(App):
def handle_404(self, request: Request) -> Tuple[str, APIResponse]:
return (
HTTP_404,
APIResponse(
status=404,
message=f"The resource you are looking for {request.scheme}://{request.host}{request.path} doesn't exist",
),
)
# We are passing explicitly the engine param for
# establishing the utc as the timezone for our connection.
settings = Settings(
{
"database_engine_dsn": os.getenv(
"DATABASE_DSN", "postgres://molten:local@localhost/market_look"
),
"database_engine_params": {
"echo": True,
"connect_args": {"options": "-c timezone=utc"},
},
}
)
get_schema = OpenAPIHandler(
metadata=Metadata(
title="Market Look API",
description="An API for managing promotion compliance at store.",
version="0.1.0",
)
)
get_docs = OpenAPIUIHandler()
setattr(get_docs, "openapi_tags", ["API Management"])
def name(name: Optional[QueryParam]) -> APIResponse:
_name = name or "Molten"
return APIResponse(
status=200, message=f"Hello, {_name}! Glad you are programming with us"
)
def ping() -> APIResponse:
return APIResponse(200, "Pong")
@annotate(openapi_tags=["Stores"])
def get_stores(store_manager: StoreManager) -> List[Store]:
"""Returns a collection of Stores"""
stores = store_manager.get_stores()
return stores
@annotate(openapi_tags=["Stores"])
def create_store(
store: Store, store_manager: StoreManager
) -> Tuple[Any, Store, Dict[str, str]]:
"""Creates a new store resource and returns its respresentation"""
try:
_store = store_manager.create_store(store)
except HTTPError as err:
raise err
headers = {"Location": _store.href}
return HTTP_201, _store, headers
@annotate(openapi_tags=["Stores"])
def get_store_by_id(id: int, store_manager: StoreManager) -> Store:
_store = store_manager.get_store_by_id(id)
if _store is None:
raise HTTPError(
HTTP_404,
{
"status": 404,
"message": f"The resource you are looking for /stores/{id} does not exist",
},
)
return _store
@annotate(openapi_tags=["Stores"])
def update_store(store: Store, store_manager: StoreManager) -> Store:
_updated_store = store_manager.update_store(store)
return _updated_store
@annotate(openapi_tags=["Stores"])
def delete_store_by_id(id: int, store_manager: StoreManager) -> Tuple[Any, APIResponse]:
store_manager.delete_store_by_id(id)
return (
HTTP_202,
APIResponse(status=202, message=f"Delete request for store: {id} accepted"),
)
@annotate(openapi_tags=["Geographies"])
def get_geographies(geo_manager: GeographyManager) -> List[Geography]:
_geographies = geo_manager.get_geographies()
return _geographies
@annotate(openapi_tags=["Geographies"])
def get_geo_by_id(id: int, geo_manager: GeographyManager) -> Geography:
try:
_geo = geo_manager.get_geo_by_id(id=id)
except HTTPError as err:
raise err
return _geo
@annotate(openapi_tags=["Geographies"])
def get_geo_stores(id: int, store_manager: StoreManager) -> List[Store]:
stores = store_manager.get_stores_by_geo(geo_id=id)
return stores
@annotate(openapi_tags=["Alerts"])
def get_alerts(alert_manager: AlertManager) -> List[Alert]:
alerts = alert_manager.get_alerts()
return alerts
@annotate(openapi_tags=["Alerts"])
def get_alert_by_id(id: int, alert_manager: AlertManager) -> Alert:
try:
alert = alert_manager.get_alert_by_id(alert_id=id)
except EntityNotFound as err:
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
return alert
@annotate(openapi_tags=["Alerts"])
def get_store_alerts(id: int, alert_manager: AlertManager) -> List[Alert]:
try:
alerts = alert_manager.get_alerts_at_store(store_id=id)
except EntityNotFound as err:
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
return alerts
@annotate(openapi_tags=["Alerts"])
def get_geo_alerts(id: int, alert_manager: AlertManager) -> List[Alert]:
try:
alerts = alert_manager.get_alerts_at_geo(geo_id=id)
except EntityNotFound as err:
raise HTTPError(HTTP_404, APIResponse(status=404, message=err.message))
return alerts
routes = [
Route("/", method="GET", handler=name),
Route("/ping", method="GET", handler=ping),
# Stores
Route("/stores", method="GET", handler=get_stores),
Route("/stores", method="POST", handler=create_store),
Route("/stores/{id}", method="GET", handler=get_store_by_id),
Route("/stores/{id}", method="PUT", handler=update_store),
Route("/stores/{id}", method="DELETE", handler=delete_store_by_id),
Route("/geographies/{id}/stores", method="GET", handler=get_geo_stores),
# Geographies
Route("/geographies", method="GET", handler=get_geographies),
Route("/geographies/{id}", method="GET", handler=get_geo_by_id),
# Alerts
Route("/alerts", method="GET", handler=get_alerts),
Route("/alerts/{id}", method="GET", handler=get_alert_by_id),
Route("/stores/{id}/alerts", method="GET", handler=get_store_alerts),
Route("/geographies/{id}/alerts", method="GET", handler=get_geo_alerts),
# OpenAPI
Route("/_schema", get_schema),
Route("/docs", get_docs),
]
components = [
SettingsComponent(settings),
SQLAlchemyEngineComponent(),
SQLAlchemySessionComponent(),
StoreManagerComponent(),
GeographyManagerComponent(),
AlertManagerComponent()
]
middleware = [ResponseRendererMiddleware(), SQLAlchemyMiddleware()]
renderers = [ExtJSONRender()]
app = MarketLook(
routes=routes, components=components, middleware=middleware, renderers=renderers
)

@ -0,0 +1,87 @@
from decimal import Decimal
import click
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from werkzeug.serving import run_simple
from .app import app, settings
from .model import Base, UserModel, GeographyModel, StoreModel, AlertModel
engine = create_engine(settings.get("database_engine_dsn"))
Session = sessionmaker(bind=engine)
@click.group()
def cli():
pass
@click.command()
def serve(host="0.0.0.0", port=8080, debug=True, use_reloader=True):
run_simple(host, port, app, use_debugger=debug, use_reloader=use_reloader)
@click.command()
def initdb():
click.echo("Creating database")
Base.metadata.create_all(bind=engine)
click.echo("Database created")
@click.command()
def dropdb():
click.echo("Are you sure you would like to drop the database?: [Y/N]")
response = input()
if response.lower() == "y":
Base.metadata.drop_all(bind=engine)
click.echo("Database dropped")
else:
click.echo("Database drop aborted")
@click.command()
@click.argument("email", type=str)
@click.argument("passwd", type=str)
def adduser(email, passwd):
user = UserModel(email=email, password=passwd, admin=False)
session = Session()
session.add(user)
session.commit()
click.echo(f"New user {user.email} has been created")
session.close()
@click.command()
def testdata():
session = Session()
user = UserModel(email="test@kellogg.com", password="Welcome1")
geography = GeographyModel(id=500_000, name="TEST", user=user)
store = StoreModel(
id=9_000_000_000,
name="SUPER TEST STORE 4000",
number="4000",
address="123 FAKE STREET",
city="PORTAGE",
zip="49024",
state="MI",
lat=Decimal("42.2607409"),
long=Decimal("-85.6121787"),
tdlinx="0123456",
geography=geography,
)
alert = AlertModel(store=store, promo_name="BATMAN CHEEZIT")
session.add_all([user, geography, store, alert])
session.commit()
click.echo("Test Data created")
session.close()
cli.add_command(serve)
cli.add_command(initdb)
cli.add_command(dropdb)
cli.add_command(adduser)
cli.add_command(testdata)
if __name__ == "__main__":
cli()

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

@ -0,0 +1,261 @@
from abc import ABCMeta, abstractmethod
from inspect import Parameter
import typing
from sqlalchemy.orm import Session
from molten import BaseApp, HTTPError, HTTP_409, HTTP_404
from .errors import EntityNotFound
from .model import StoreModel, GeographyModel, AlertModel
from .schema import Alert, Geography, Link, Store, APIResponse
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
class StoreManager(BaseManager):
"""A `StoreManager` is accountable for the CRUD operations associated with a Store"""
def _schema_from_model(self, result: StoreModel) -> Store:
_store = Store(
id=result.id,
href=self.app.reverse_uri("get_store_by_id", id=result.id),
createdDate=result.created_date,
modifiedDate=result.modified_date,
name=result.name,
number=result.number,
address=result.address,
city=result.city,
state=result.state,
zip=result.zip,
lat=result.lat,
long=result.long,
tdlinx=result.tdlinx,
geography=Link(
href=self.app.reverse_uri("get_geo_by_id", id=result.geography_id)
)
if result.geography_id
else None,
alerts=Link(
href=self.app.reverse_uri("get_store_alerts", id=result.id)
)
)
return _store
def _model_from_schema(self, store: Store) -> StoreModel:
_store_model = StoreModel(
id=store.id,
name=store.name,
number=store.number,
address=store.address,
city=store.city,
state=store.state,
zip=store.zip,
lat=store.lat,
long=store.long,
tdlinx=store.tdlinx,
)
return _store_model
def get_stores(self) -> typing.List[Store]:
"""Retrieves a list of Store representations"""
results = self.session.query(StoreModel).order_by(StoreModel.id).all()
_stores = [self._schema_from_model(result) for result in results]
return _stores
def get_store_by_id(self, id: int) -> Store:
"""Retrieves a store representation by id"""
result = self.session.query(StoreModel).filter_by(id=id).one_or_none()
if result is None:
return result
_store = self._schema_from_model(result)
return _store
def create_store(self, store: Store) -> Store:
"""Creates a new store resource and returns its representation"""
result = self.session.query(StoreModel).filter_by(id=store.id).one_or_none()
if result is not None:
raise HTTPError(
HTTP_409,
{
"status": 409,
"message": f"A store with id: {store.id} already exists",
},
)
store_model = self._model_from_schema(store)
self.session.add(store_model)
self.session.flush()
_store = self._schema_from_model(store_model)
return _store
def update_store(self, store: Store) -> Store:
result = self.session.query(StoreModel).filter_by(id=store.id).one_or_none()
_updates = self._model_from_schema(store)
self.session.merge(_updates)
self.session.flush()
_store = self._schema_from_model(result)
return _store
def delete_store_by_id(self, id):
_store = self.session.query(StoreModel).filter_by(id=id).one_or_none()
if _store is not None:
self.session.delete(_store)
return
def get_stores_by_geo(self, geo_id) -> typing.List[Store]:
results = self.session.query(StoreModel).filter_by(geography_id=geo_id).all()
_stores = [self._schema_from_model(store) for store in results]
return _stores
class GeographyManager(BaseManager):
def _model_from_schema(self, schema: Geography) -> GeographyModel:
pass
def _schema_from_model(self, result: GeographyModel) -> Geography:
_geography = Geography(
id=result.id,
href=self.app.reverse_uri("get_geo_by_id", id=result.id),
createdDate=result.created_date,
modifiedDate=result.modified_date,
name=result.name,
stores=Link(href=self.app.reverse_uri('get_geo_stores', id=result.id)),
alerts=Link(href=self.app.reverse_uri('get_geo_alerts', id=result.id)),
)
return _geography
def get_geographies(self) -> typing.List[Geography]:
results = self.session.query(GeographyModel).all()
return [self._schema_from_model(geo) for geo in results]
def get_geo_by_id(self, id: int) -> Geography:
result = self.session.query(GeographyModel).one_or_none()
if result is None:
raise HTTPError(
HTTP_404,
APIResponse(
status=404,
message=f"The resource you are looking for /geographies/{id} does not exist",
),
)
return self._schema_from_model(result)
class AlertManager(BaseManager):
def _model_from_schema(self, schema: Alert) -> AlertModel:
_alert_model = AlertModel(
promo_name=schema.promoName,
response=schema.response,
valid=schema.valid
)
def _schema_from_model(self, result: AlertModel) -> Alert:
_alert = Alert(
id=result.id,
href=self.app.reverse_uri('get_alert_by_id', id=result.id),
createdDate=result.created_date,
modifiedDate=result.modified_date,
promoName=result.promo_name,
response=result.response,
valid=result.valid,
store=Link(href=self.app.reverse_uri('get_store_by_id', id=result.store.id))
)
return _alert
def get_alerts(self) -> typing.List[Alert]:
results = self.session.query(AlertModel).all()
alerts = [self._schema_from_model(alert) for alert in results]
return alerts
def get_alert_by_id(self, alert_id) -> Alert:
result = self.session.query(AlertModel).filter_by(id=alert_id).one_or_none()
if result is None:
raise EntityNotFound(f"Alert: {alert_id} does not exist")
alert = self._schema_from_model(result)
return alert
def get_alerts_at_store(self, store_id) -> typing.List[Alert]:
store_check = self.session.query(StoreModel.id).filter_by(id=store_id).exists()
if not self.session.query(store_check).scalar():
raise EntityNotFound(f'Store {store_id} does not exist')
results = self.session.query(AlertModel).filter_by(store_id=store_id, active=True).all()
alerts = [self._schema_from_model(alert) for alert in results]
return alerts
def get_alerts_at_geo(self, geo_id) -> typing.List[Alert]:
geo_check = self.session.query(GeographyModel.id).filter_by(id=geo_id).exists()
if not self.session.query(geo_check).scalar():
raise EntityNotFound(f'Geography {geo_id} does not exist')
_subquery = self.session.query(StoreModel.id).filter(StoreModel.geography_id==geo_id)
results = self.session.query(AlertModel).filter(AlertModel.store_id.in_(_subquery)).all()
alerts = [self._schema_from_model(alert) for alert in results]
return alerts
def create_alert_at_store(self, store_id, alert: Alert) -> Alert:
store = self.session.query(StoreModel).filter_by(id=store_id).one()
alert_model = self._model_from_schema(alert)
alert_model.store = store
self.session.add(alert_model)
self.session.flush()
_alert = self._schema_from_model(alert_model)
return _alert
class StoreManagerComponent:
is_cacheable = True
is_singleton = False
def can_handle_parameter(self, parameter: Parameter) -> bool:
return parameter.annotation is StoreManager
def resolve(self, session: Session, app: BaseApp) -> StoreManager: # type: ignore
return StoreManager(session, app)
class GeographyManagerComponent:
is_cacheable = True
is_singleton = False
def can_handle_parameter(self, parameter: Parameter) -> bool:
return parameter.annotation is GeographyManager
def resolve(self, session: Session, app: BaseApp) -> StoreManager: # type: ignore
return GeographyManager(session, app)
class AlertManagerComponent:
is_cacheable = True
is_singleton = False
def can_handle_parameter(self, parameter: Parameter) -> bool:
return parameter.annotation is AlertManager
def resolve(self, session: Session, app: BaseApp) -> AlertManager: # type: ignore
return AlertManager(session, app)

@ -0,0 +1,103 @@
import bcrypt
from sqlalchemy import (
Column,
Integer,
BigInteger,
Numeric,
String,
ForeignKey,
DateTime,
Boolean,
Enum,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import expression
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import DateTime as DatetimeType
BCRYPT_LOG_ROUNDS = 11
Base = declarative_base()
class utcnow(expression.FunctionElement):
type = DatetimeType()
@compiles(utcnow, "postgresql")
def pg_utcnow(element, compiler, **kw):
return "TIMEZONE('utc', CURRENT_TIMESTAMP)"
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()
)
class UserModel(DBMixin, Base):
__tablename__ = "user"
email = Column(String(255), nullable=False, unique=True)
password = Column(String(255))
geography = relationship("GeographyModel", back_populates="user")
admin = Column(Boolean, nullable=False, default=False)
confirmed = Column(Boolean, nullable=False, default=False)
active = Column(Boolean, nullable=False, default=True)
def __init__(self, email, password, admin=False):
self.email = email
self.password = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt(BCRYPT_LOG_ROUNDS)
).decode()
self.admin = admin
def check_password(self, password):
return bcrypt.checkpw(password.encode("utf-8"), self.password.encode("utf-8"))
class GeographyModel(Base, DBMixin):
__tablename__ = "geography"
name = Column(String(255))
user_id = Column(BigInteger, ForeignKey("user.id"), nullable=True)
active = Column(Boolean, nullable=False, default=True)
user = relationship("UserModel", uselist=False, back_populates="geography")
stores = relationship(
"StoreModel", order_by="StoreModel.id", back_populates="geography"
)
class StoreModel(Base, DBMixin):
__tablename__ = "store"
geography_id = Column(BigInteger, ForeignKey("geography.id"), nullable=True)
name = Column(String(255))
number = Column(String(4), nullable=True)
address = Column(String(255))
city = Column(String(255))
state = Column(String(2))
zip = Column(String(12))
lat = Column(Numeric(10, 7), nullable=True)
long = Column(Numeric(10, 7), nullable=True)
tdlinx = Column(String(8), nullable=True)
active = Column(Boolean, nullable=False, default=True)
geography = relationship("GeographyModel", back_populates="stores")
alerts = relationship("AlertModel", back_populates="store")
class AlertModel(Base, DBMixin):
__tablename__ = "alert"
store_id = Column(BigInteger, ForeignKey("store.id"))
store = relationship("StoreModel", back_populates="alerts")
promo_name = Column(String(255))
response = Column(
Enum("", "manager_refused", "valid", "invalid", name="response_type")
)
responded = Column(Boolean, default=False)
valid = Column(Boolean, nullable=True)
active = Column(Boolean, nullable=False, default=True)

@ -0,0 +1,79 @@
from typing import Optional, List, Union
from molten import schema, field
@schema
class Link:
href: str
@schema
class Geography:
id: int
href: str = field(response_only=True)
createdDate: str = field(response_only=True)
modifiedDate: str = field(response_only=True)
name: str
stores: Link = field(response_only=True)
alerts: Link = field(response_only=True)
@schema
class Store:
id: int
href: str = field(response_only=True)
createdDate: str = field(response_only=True)
modifiedDate: str = field(response_only=True)
name: str
number: Optional[str]
address: str
city: str
state: str
zip: str
lat: Optional[float]
long: Optional[float]
tdlinx: Optional[str]
geography: Link = field(response_only=True)
alerts: Link = field(response_only=True)
@schema
class User:
id: int = field(response_only=True)
href: str = field(response_only=True)
email: str
password: str = field(request_only=True)
geography: Link = field(response_only=True)
@schema
class Alert:
id: int
href: str = field(response_only=True)
createdDate: str = field(response_only=True)
modifiedDate: str = field(response_only=True)
promoName: str
response: Optional[str]
valid: Optional[bool]
store: Link = field(response_only=True)
@schema
class StorePatch:
name: Optional[str]
number: Optional[str]
address: Optional[str]
city: Optional[str]
state: Optional[str]
zip: Optional[str]
lat: Optional[float]
long: Optional[float]
tdlinx: Optional[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,21 @@
import datetime as dt
from decimal import Decimal
from typing import Any
from molten import JSONRenderer, is_schema, dump_schema
class ExtJSONRender(JSONRenderer):
"""JSON Render with support for ISO 8601 datetime format strings"""
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,5 @@
bcrypt
molten
psycopg2
sqlalchemy

@ -0,0 +1,19 @@
#
# 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
bcrypt==3.1.4
cffi==1.11.5 # via bcrypt
molten==0.5.0
mypy-extensions==0.4.0 # via typing-inspect
psycopg2==2.7.5
pycparser==2.18 # via cffi
six==1.11.0 # via bcrypt
sqlalchemy==1.2.10
typing-extensions==3.6.5 # via molten
typing-inspect==0.3.1 # via molten

@ -0,0 +1,15 @@
from setuptools import setup, find_packages
setup(
name="Market Look",
description="An API application used for tracking promotion compliance at store",
zip_safe=False,
author="Drew Bednar",
author_email="andrew.bednar@kellogg.com",
packages=find_packages(exclude=['tests'], include=['scripts', 'market_look']),
entry_points={
"console_scripts": [
"manage=market_look.cli:cli"
]
}
)
Loading…
Cancel
Save