From 7de920f94824b0a0f3131b29e2731a1246f13267 Mon Sep 17 00:00:00 2001 From: John Rofrano Date: Thu, 5 Mar 2020 14:29:40 -0500 Subject: [PATCH] initial version --- .coveragerc | 6 +++ .gitignore | 69 ++++++++----------------- README.md | 40 +++++++++++++++ Vagrantfile | 81 +++++++++++++++++++++++++++++ config.py | 14 +++++ dot-env-example | 2 + requirements.txt | 12 +++++ service/__init__.py | 42 +++++++++++++++ service/models.py | 115 ++++++++++++++++++++++++++++++++++++++++++ service/service.py | 37 ++++++++++++++ tests/__init__.py | 0 tests/test_models.py | 40 +++++++++++++++ tests/test_service.py | 48 ++++++++++++++++++ 13 files changed, 459 insertions(+), 47 deletions(-) create mode 100644 .coveragerc create mode 100644 Vagrantfile create mode 100644 config.py create mode 100644 dot-env-example create mode 100644 requirements.txt create mode 100644 service/__init__.py create mode 100644 service/models.py create mode 100644 service/service.py create mode 100644 tests/__init__.py create mode 100644 tests/test_models.py create mode 100644 tests/test_service.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..cf93d35 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +# .coveragerc +[run] +source=service + +[report] +show_missing = True diff --git a/.gitignore b/.gitignore index b6e4761..bfbbf83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ +# Local OS metadata +.DS_Store +Thumbs.db + +# VS Code +.vscode/ + +# Vagrant +.vagrant + +# databases +*.db +db/* +!db/.keep + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -8,6 +23,7 @@ __pycache__/ # Distribution / packaging .Python +env/ build/ develop-eggs/ dist/ @@ -19,13 +35,9 @@ lib64/ parts/ sdist/ var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -40,16 +52,13 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ -.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*.cover -*.py,cover +*,cover .hypothesis/ -.pytest_cache/ # Translations *.mo @@ -58,8 +67,6 @@ coverage.xml # Django stuff: *.log local_settings.py -db.sqlite3 -db.sqlite3-journal # Flask stuff: instance/ @@ -74,56 +81,24 @@ docs/_build/ # PyBuilder target/ -# Jupyter Notebook +# IPython Notebook .ipynb_checkpoints -# IPython -profile_default/ -ipython_config.py - # pyenv .python-version -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff +# celery beat schedule file celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py -# Environments +# dotenv .env -.venv -env/ + +# virtualenv venv/ ENV/ -env.bak/ -venv.bak/ # Spyder project settings .spyderproject -.spyproject # Rope project settings .ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/README.md b/README.md index 4b7bb40..f4c4f76 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # project-template + This is a skeleton you can use to start your projects + +## Overview + +This project template contains starter code for your class project. The `/service` folder contains your `models.py` file for your model and a `service.py` file for your service. The `/tests` folder has test case starter code for testing the model and the service separately. All you need to do is add your functionality. You can use the [lab-flask-rest](https://github.com/nyu-devops/lab-flask-rest) +for code examples. + +## Setup + +You should clone this repository and and the copy and paste the starter code into your project repo folder on your local computer. + +Assuming your repo is empty, you can use the following commands: + +```shell + git clone https://github.com/nyu-devops/project-template.git + cp -R project-template/ / +``` + +This will recursively copy the contents of the template to your repo folder. + +## Contents + +The project contains the following: + +```text +dot-env-example - copy to .env to use environment variables +config.py - configuration parameters + +service/ - service python package +├── __init__.py - package initializer +├── models.py - module with business models +└── service.py - module with service routes + +tests/ - test cases package +├── __init__.py - package initializer +├── test_models.py - test suite for busines models +└── test_service.py - test suite for service routes +``` + +This repository is part of the NYU class **CSCI-GA.2810-001: DevOps and Agile Methodologies** taught by John Rofrano, Adjunct Instructor, NYU Curant Institute, Graduate Division, Computer Science. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..39351bf --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,81 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# All Vagrant configuration is done below. The "2" in Vagrant.configure +# configures the configuration version (we support older styles for +# backwards compatibility). Please don't change it unless you know what +# you're doing. +Vagrant.configure(2) do |config| + # Every Vagrant development environment requires a box. You can search for + # boxes at https://atlas.hashicorp.com/search. + config.vm.box = "ubuntu/bionic64" + config.vm.hostname = "project" + + # accessing "localhost:8080" will access port 80 on the guest machine. + # config.vm.network "forwarded_port", guest: 80, host: 8080 + config.vm.network "forwarded_port", guest: 5000, host: 5000, host_ip: "127.0.0.1" + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + config.vm.network "private_network", ip: "192.168.33.10" + + # Mac users can comment this next line out but + # Windows users need to change the permission of files and directories + # so that nosetests runs without extra arguments. + config.vm.synced_folder ".", "/vagrant", mount_options: ["dmode=775,fmode=664"] + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + config.vm.provider "virtualbox" do |vb| + # Customize the amount of memory on the VM: + vb.memory = "1024" + vb.cpus = 2 + # Fixes some DNS issues on some networks + vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] + vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"] + end + + # Copy your .gitconfig file so that your git credentials are correct + if File.exists?(File.expand_path("~/.gitconfig")) + config.vm.provision "file", source: "~/.gitconfig", destination: "~/.gitconfig" + end + + # Copy the ssh keys into the vm for git access + if File.exists?(File.expand_path("~/.ssh/id_rsa")) + config.vm.provision "file", source: "~/.ssh/id_rsa", destination: "~/.ssh/id_rsa" + end + + if File.exists?(File.expand_path("~/.ssh/id_rsa.pub")) + config.vm.provision "file", source: "~/.ssh/id_rsa.pub", destination: "~/.ssh/id_rsa.pub" + end + + # Copy your .vimrc file so that your vi looks like you expect + if File.exists?(File.expand_path("~/.vimrc")) + config.vm.provision "file", source: "~/.vimrc", destination: "~/.vimrc" + end + + # Enable provisioning with a shell script. Additional provisioners such as + # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the + # documentation for more information about their specific syntax and use. + config.vm.provision "shell", inline: <<-SHELL + apt-get update + apt-get install -y git python3 python3-pip python3-venv + apt-get -y autoremove + # Install app dependencies + cd /vagrant + pip3 install -r requirements.txt + SHELL + + ###################################################################### + # Add PostgreSQL docker container + ###################################################################### + # docker run -d --name postgres -p 5432:5432 -v psql_data:/var/lib/postgresql/data postgres + config.vm.provision :docker do |d| + d.pull_images "postgres:alpine" + d.run "postgres:alpine", + args: "-d --name postgres -p 5432:5432 -v psql_data:/var/lib/postgresql/data -e POSTGRES_PASSWORD=postgres" + end + +end diff --git a/config.py b/config.py new file mode 100644 index 0000000..e86c620 --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +""" +Global Configuration for Application +""" +import os + +# Get configuration from environment +DATABASE_URI = os.getenv("DATABASE_URI", "sqlite:///../development.db") + +# Configure SQLAlchemy +SQLALCHEMY_DATABASE_URI = DATABASE_URI +SQLALCHEMY_TRACK_MODIFICATIONS = False + +# Secret for session management +SECRET_KEY = os.getenv("SECRET_KEY", "s3cr3t-key-shhhh") diff --git a/dot-env-example b/dot-env-example new file mode 100644 index 0000000..5e18240 --- /dev/null +++ b/dot-env-example @@ -0,0 +1,2 @@ +# Copy this file to .env to expose these environment variables +FLASK_APP=service:app diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23ae5c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# Runtime dependencies +Flask==1.1.1 +Flask-API==1.1 +Flask-SQLAlchemy==2.4.1 +python-dotenv==0.10.3 + +# Testing +nose==1.3.7 +rednose==1.3.0 +pinocchio==0.4.2 +coverage==4.5.4 +pylint>=2.4.1 diff --git a/service/__init__.py b/service/__init__.py new file mode 100644 index 0000000..4be3b2f --- /dev/null +++ b/service/__init__.py @@ -0,0 +1,42 @@ +""" +Package: service +Package for the application models and service routes +This module creates and configures the Flask app and sets up the logging +and SQL database +""" +import os +import sys +import logging +from flask import Flask + +# Create Flask application +app = Flask(__name__) +app.config.from_object('config') + +# Import the rutes After the Flask app is created +from service import service, models + +# Set up logging for production +if __name__ != '__main__': + gunicorn_logger = logging.getLogger('gunicorn.error') + app.logger.handlers = gunicorn_logger.handlers + app.logger.setLevel(gunicorn_logger.level) + app.logger.propagate = False + # Make all log formats consistent + formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s", "%Y-%m-%d %H:%M:%S %z") + for handler in app.logger.handlers: + handler.setFormatter(formatter) + app.logger.info('Logging handler established') + +app.logger.info(70 * "*") +app.logger.info(" M Y S E R V I C E R U N N I N G ".center(70, "*")) +app.logger.info(70 * "*") + +try: + service.init_db() # make our sqlalchemy tables +except Exception as error: + app.logger.critical("%s: Cannot continue", error) + # gunicorn requires exit code 4 to stop spawning workers when they die + sys.exit(4) + +app.logger.info("Service inititalized!") diff --git a/service/models.py b/service/models.py new file mode 100644 index 0000000..065e382 --- /dev/null +++ b/service/models.py @@ -0,0 +1,115 @@ +""" +Models for + +All of the models are stored in this module +""" +import logging +from flask_sqlalchemy import SQLAlchemy + +logger = logging.getLogger("flask.app") + +# Create the SQLAlchemy object to be initialized later in init_db() +db = SQLAlchemy() + +class DataValidationError(Exception): + """ Used for an data validation errors when deserializing """ + pass + + +class YourResourceModel(db.Model): + """ + Class that represents a + """ + + app = None + + # Table Schema + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(63)) + + def __repr__(self): + return "< %r id=[%s]>" % (self.name, self.id) + + def create(self): + """ + Creates a to the database + """ + logger.info("Creating %s", self.name) + self.id = None # id must be none to generate next primary key + db.session.add(self) + db.session.commit() + + def save(self): + """ + Updates a to the database + """ + logger.info("Saving %s", self.name) + db.session.commit() + + def delete(self): + """ Removes a from the data store """ + logger.info("Deleting %s", self.name) + db.session.delete(self) + db.session.commit() + + def serialize(self): + """ Serializes a into a dictionary """ + return { + "id": self.id, + "name": self.name + } + + def deserialize(self, data): + """ + Deserializes a from a dictionary + + Args: + data (dict): A dictionary containing the resource data + """ + try: + self.name = data["name"] + except KeyError as error: + raise DataValidationError("Invalid : missing " + error.args[0]) + except TypeError as error: + raise DataValidationError( + "Invalid : body of request contained" "bad or no data" + ) + return self + + @classmethod + def init_db(cls, app): + """ Initializes the database session """ + logger.info("Initializing database") + cls.app = app + # This is where we initialize SQLAlchemy from the Flask app + db.init_app(app) + app.app_context().push() + db.create_all() # make our sqlalchemy tables + + @classmethod + def all(cls): + """ Returns all of the s in the database """ + logger.info("Processing all s") + return cls.query.all() + + @classmethod + def find(cls, by_id): + """ Finds a by it's ID """ + logger.info("Processing lookup for id %s ...", by_id) + return cls.query.get(by_id) + + @classmethod + def find_or_404(cls, by_id): + """ Find a by it's id """ + logger.info("Processing lookup or 404 for id %s ...", by_id) + return cls.query.get_or_404(by_id) + + @classmethod + def find_by_name(cls, name): + """ Returns all s with the given name + + Args: + name (string): the name of the s you want to match + """ + logger.info("Processing name query for %s ...", name) + return cls.query.filter(cls.name == name) diff --git a/service/service.py b/service/service.py new file mode 100644 index 0000000..b12b377 --- /dev/null +++ b/service/service.py @@ -0,0 +1,37 @@ +""" +My Service + +Describe what your service does here +""" + +import os +import sys +import logging +from flask import Flask, jsonify, request, url_for, make_response, abort +from flask_api import status # HTTP Status Codes + +# For this example we'll use SQLAlchemy, a popular ORM that supports a +# variety of backends including SQLite, MySQL, and PostgreSQL +from flask_sqlalchemy import SQLAlchemy +from service.models import YourResourceModel, DataValidationError + +# Import Flask application +from . import app + +###################################################################### +# GET INDEX +###################################################################### +@app.route("/") +def index(): + """ Root URL response """ + return "Reminder: return some useful information in json format about the service here", status.HTTP_200_OK + + +###################################################################### +# U T I L I T Y F U N C T I O N S +###################################################################### + +def init_db(): + """ Initialies the SQLAlchemy app """ + global app + YourResourceModel.init_db(app) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..1d951eb --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,40 @@ +""" +Test cases for Model + +""" +import logging +import unittest +import os +from service.models import YourResourceModel, DataValidationError, db + +###################################################################### +# M O D E L T E S T C A S E S +###################################################################### +class TestYourResourceModel(unittest.TestCase): + """ Test Cases for Model """ + + @classmethod + def setUpClass(cls): + """ This runs once before the entire test suite """ + pass + + @classmethod + def tearDownClass(cls): + """ This runs once after the entire test suite """ + pass + + def setUp(self): + """ This runs before each test """ + pass + + def tearDown(self): + """ This runs after each test """ + pass + +###################################################################### +# P L A C E T E S T C A S E S H E R E +###################################################################### + + def test_XXXX(self): + """ Test something """ + self.assertTrue(True) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..a7a2779 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,48 @@ +""" + API Service Test Suite + +Test cases can be run with the following: + nosetests -v --with-spec --spec-color + coverage report -m +""" +import os +import logging +from unittest import TestCase +from unittest.mock import MagicMock, patch +from flask_api import status # HTTP Status Codes +from service.models import db +from service.service import app, init_db + +###################################################################### +# T E S T C A S E S +###################################################################### +class TestYourResourceServer(TestCase): + """ Server Tests """ + + @classmethod + def setUpClass(cls): + """ This runs once before the entire test suite """ + pass + + @classmethod + def tearDownClass(cls): + """ This runs once after the entire test suite """ + pass + + def setUp(self): + """ This runs before each test """ + self.app = app.test_client() + + + def tearDown(self): + """ This runs after each test """ + pass + +###################################################################### +# P L A C E T E S T C A S E S H E R E +###################################################################### + + def test_index(self): + """ Test index call """ + resp = self.app.get("/") + self.assertEqual(resp.status_code, status.HTTP_200_OK)