Getting Started with Python Microservices in Flask

Creating Python Microservices, Part 1

Code for this can be found on GitHub. Or you can use this template as a starting point.

The Microservice Scenario

So here’s the situation: you’ve added data scientists to your team, and they’re coming up with great ideas that they’re implementing in jupyter. Lots of these new bits and pieces need to make it into the rest of your non-python system, so you decide that these small bits of logic should be deployed separately as microservices. The problem is that there are lot of things you need to do to get something like this into production, and… you know very little about Python. Or microservice devops.

This short series describes from end-to-end how to take a simple python function and publish it as a RESTful service in the Azure cloud using Kubernetes. It will also mention some important practical issues like testing, security, scaling, capturing output and logging.

The ultimate goal is to arrive at the point where developers can concentrate on business logic and get their code into production from the command line without devops issues standing in the way.

Although most of this code is not platform-specific, I’m doing this on Windows, so the scripting is in PowerShell rather than bash.

Set up Python on Windows

Some people use Anaconda on Windows, but I just use the standard Python distribution from the python.org site.

Once you have Python installed (and it’s in your PATH), you should set up a virtual environment. I’m calling this project pythondemo, so I’ll also create a virtual environment with the same name. You’ll see this project name appear in various places as we proceed.

I had problems (in 2019!) with spaces in the virtual environment path, so I put pythondemo in my space-free home directory using venv

# create the virtual environment
PS> mkdir ~/venv
PS> python -m venv ~/venv/pythondemo

# run the virtual environment
PS> ~\venv\pythondemo\Scripts\activate.ps1

# upgrade pip so it stops complaining every time you run it
(pythondemo) PS> python -m pip install --upgrade pip

I also created a file src/requirements.txt which has the following lines—we’ll need all these eventually, so we may as well install them now:

# src/requirements.txt

Flask==1.0.2
pytest==4.6.2
flask-inputs==0.3.0
jsonschema==3.0.1
> cd src
> python -m pip install -r .\requirements.txt

We should now have everything we need to get a rest service running locally in Python.

Python Microservices, Starting from Zero

The business logic for this demo is going to be simple: we want to be able to greet people by name.

“Hello, your name

def say_hello_to(s):
    pass

If you don’t know much about python, you’ll quickly realize that the module loading system is confusing. At least, I find it confusing and I can see that I’m not the only one. Here’s my current recommendation for laying out the directories to minimize the pain:

src/app         # the Flask REST interface code
src/mypkg       # the business logic 
src/tests/app   # unit tests for the Flask app
src/tests/mypkg # unit tests for the business logic

Put a magical empty file called __init__.py in src, and every directory under src/app, and src/mypkg.

We’ll start out with good TDD habits and write a test for our business logic first:

# src/tests/mypkg/test_greeter.py

from mypkg.greetings import say_hello_to

def test_say_hello_appends_name():
    assert say_hello_to("world") == "hello world"

Run it and watch it fail:

> python -m pytest -s

========================================= FAILURES ===============================================
________________________________ test_say_hello_appends_name _____________________________________

    def test_say_hello_appends_name():
>       assert say_hello_to("world") == "hello world"
E       AssertionError: assert None == 'hello world'
E        +  where None = say_hello_to('world')

tests\test_greetings.py:5: AssertionError
================================ 1 failed in 0.08 seconds ========================================

If you know how to make pytest load modules from the command-line, that’s great. It doesn’t work for me, but I’ve decided that I have exceeded my life-quota of debugging python-module-loading issues, so I invoke it with python -m pytest and add -s to see stuff logged to the console.

Now let’s make it pass:

def say_hello_to(s):
   return "hello {}".format(s)
python -m pytest -s

If that worked, we’re done with the business logic. Onward to the RESTful wrapper.

RESTful Python via Flask

Flask is the most common way to publish a RESTful microservice in Python. Let’s get a simple API working just to make sure we’re doing it right:

# src/app/main.py:

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/")
def index() -> str:
    # transform a dict into an application/json response 
    return jsonify({"message": "It Works"})
        
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)        

Note that the if __name__ == '__main__' part is just for debugging—flask normally runs your app in production as a python module.

Let’s run it:

> cd src
> python -m app.main

If all went well, you should now be able to navigate to http://localhost and see your hardcoded JSON:

{"message": "It works"}

Let’s set up a simple pytest test while things are simple so we know how writing tests is done. First create a conftest.py pytest fixture file to create a testing client for us (more info here):

# src/tests/conftest.py

import pytest
from app import main

# see: http://flask.pocoo.org/docs/1.0/testing/
@pytest.fixture
def client():
    main.app.config['TESTING'] = True
    client = main.app.test_client()
    yield client

Now let’s use it:

# src/tests/app/test_main.py

def test_info(client):
    response = client.get('/')
    result = response.get_json()
    assert result is not None
    assert "message" in result
    assert result["message"] == "It Works"

Do the tests still pass?

python -m pytest -s

Great—let’s wrap our “greeting” business logic as an HTTP POST call. (Yes, this isn’t very RESTful, but this is just an example.)

We’ll change the imports in main.py and add a new route to handle calls to /hello:

# src/app/main.py
       
from flask import Flask, jsonify, request
from mypkg.greetings import say_hello_to
            
# ...
            
@app.route("/hello", methods=['POST'])
def hello() -> str:
    greetee = request.json.get("greetee", None)
    response = {"message": say_hello_to(greetee)}
    return jsonify(response)

This isn’t production-quality yet, but it should provide the basics. From postman or curl, send a json post to http://localhost/hello with the application/json payload "{"greetee": "world"}". Did it work?

Let’s add a unit test. Note that the flask test client is found in the pytest fixtures file and passed in as a fixture.

# src/tests/app/test_main.py

# http://flask.pocoo.org/docs/1.0/testing/#testing-json-apis
def test_hello_greets_greetee(client):
   request_payload = {"greetee": "world"}
   response = client.post("/hello", json=request_payload)
   result = response.get_json()

   assert response.status_code == 200
   assert result is not None
   assert "message" in result
   assert result['message'] == "hello world"

That test shows that flask is connecting to our business logic and greeting users by name.

Next is input validation, described in Part 2.