4

I am in the process of making my first ever flask/python web app. The app initially displays a form which the user is invited to fill in, then they click on a "submit" button, then the server runs the simulation and creates a PNG file with a graph showing the results, then finally the page is redrawn with the graph displayed. My python code is roughly of this form:

# flask_app.py

@app.route("/", methods=["POST", "GET"])
def home():

    if request.method == 'POST':
        # bunch of request.form things to scoop the contents of the form

    if form_answers_all_good:
        for i in range(huge_number):
            # some maths
        # create png file with results

    return render_template("index.htm", foo=bar)

The program is working just fine, but the huge_number loop can take several tens of seconds. So what I would like is some sort of progress indicator - it doesn't have to be a slick animation - even a string readout of the percentage progress would be fine.

Presumably I can change my for loop to something like...

    for i in range(huge_number):
        # some maths
        percentage_done = str(i * 100/huge_number)

and then somehow arrange on the client side to read (poll?) percentage_done so that I put something like:

Completed {% percentage_done %}% so far.

in my index.htm. BTW, my knowledge of things like Javascript, AJAX or come to think of it, almost anything on the client side (apart from HTML) is beginner level.

I have seen a lot of explanations of similar sounding problems but generally they are doing far more complex things than I actually need and I fail to understand them because of my lack of client side knowledge. So for example some solutions might include a snippet of code and I won't actually know where to put it, or I won't know that something else needs to be loaded first in order for the snippet to work.

EDIT: I am hosting my web app on pythonanywhere.com. The list of included modules is here.

EDIT: pythonanywhere.com does not allow streaming :-(

Mick
  • 8,284
  • 22
  • 81
  • 173
  • The other answers you have seen are complex because, despite sounding like a simple modification, this is quite a complicated thing to achieve. Usually you would build this logic in the client, by returning a polling URL to the client & letting it poll the server for progress updates & update the HTML. On the server side you'd need to make the API asynchronous, by doing the processing in the background & saving it somewhere the polling API can pick up from. – rdas Apr 18 '20 at 16:35
  • This is a complex task. You have two general options: 1) Open a long lasting connection between the client and the server, either by long-polling/websockets so that the server can continuously provide updates to the client. 2): Speed up the process on the server. Depending on your maths you might be able to multi-thread the operation. Bonus non-ideal option: 3) Create a background task using a scheduler for Flask. Create an endpoint that checks the status of this task. Instruct your client to check that endpoint until it's completed, at which point you can fetch the data. – turnip Apr 21 '20 at 08:11
  • I would open a WebSockets connection for this, this would allow to work with push (always better than polling the server side). The websocket will send to the client the progression and in client side I would build a dynamic rendering by updating the application state. Now, the thing is that would be good with a client side framework such as Angular (with a service subscriber like reactivex check this out : http://reactivex.io/) or React. If you work only with template in serverside, the template need to render this progression dynamically and send it back to the client. – jossefaz Apr 21 '20 at 08:14
  • @Mick , what is the structure you plan to use on python anywhere. just run flask in the `web` section of their website ? or you plan to organize in other way ? I think it's doable... – Bernardo stearns reisen Apr 24 '20 at 10:54
  • @Bernado: I'm sorry I don't understand the question. I think I just followed their vanilla documentation and did everything in the way they suggest. You end up with an app that appears at my_chosen_name.pythonanywhere.com – Mick Apr 24 '20 at 11:42
  • Yeah, that's what I wanted to know :) if you're just using the default flask structure they show you. I will elaborate an answer then! – Bernardo stearns reisen Apr 24 '20 at 11:48
  • @Mick I added an answer using pythonanywhere – Bernardo stearns reisen Apr 25 '20 at 06:44
  • Not sure if this has already been mentioned in a comment somewhere, but Miguel Grinberg has an *excellent* [blog post/tutorial](https://blog.miguelgrinberg.com/post/using-celery-with-flask) that covers an almost identical use case. It relies on Celery, but depending on how much this project might grow in the future it could be a viable option, even if only for a bookmark. – vulpxn Apr 25 '20 at 07:11
  • @vulpxn: Interesting, but celery is not one of the modules that pythonanywhere has installed. – Mick Apr 25 '20 at 13:55
  • @Mick, celery is similar library to what `scheduler = Scheduler()` is doing in turnip's answer . it requires a multi threading enviroment, which is disabled on pythonanywhere :( – Bernardo stearns reisen Apr 25 '20 at 14:06

2 Answers2

7

You mention you're new to flask, so I am assuming you're new to flask but comfortable with python and also you are very constrained on what you can use because you're using pythonanywhere. One of the main things is it is single threaded so it makes really hard to scale anything. Also it would be better sticking with pure python as managing the dependencies in python anywhere would be an extra problem to care about, and in the end it's doable to do it using only python builtins.

I focused to show a working solution that you simple copy and paste in pythonanywhere or in your local, rather then showing snippets of code. I will try to:

  1. show the working solution
  2. describe how to replicate it
  3. breakdown the main components and briefly explain

(1) The Working solution

you can access it here(I made a lot of restrictions to try to avoid people from breaking it). The solution just involves two files, flask_app.py and index.html

How it looks like

(1.1) solution code

"./home/{username}/{flask_foldername}/flask_app.py"

from queue import Queue
import time
import random
import threading
from PIL import Image
import flask
from flask import request
import json
import io
import uuid
import base64

### You create a Queue and start a scheduler, Start flask after that
def run_scheduler(app):
    sleep_time = 5
    while True:
        time.sleep(sleep_time)
        print("\n"*5)
        print(f'images Completed:{app.images_completed}')
        print('-----'*20)
        if(app.images_toBe_processed.qsize() > 0):
            next_image_name = app.images_toBe_processed.get()
            print(f"No Images being processed so scheduler will start processing the next image {next_image_name} from the queue")
            app.function_to_process_image(next_image_name, app)
        else:
            pass

def function_to_process_image(image_name, app):
    huge_number = 5
    R = random.randint(0,256)
    G = random.randint(0,256)
    B = random.randint(0,256)
    for i in range(huge_number):
        # some maths
        percentage_done = str((i+1)*100/huge_number)
        app.images_processing_status[image_name] = percentage_done
        time.sleep(1)
    app.images_processing_status[image_name] = str(100.0)
    img = Image.new('RGB', (60, 30), color =(R,G,B))
    b=io.BytesIO()
    img.save(b, "jpeg")
    app.images_completed[image_name] = {"status":1,"file": b}
    print(f"IC from function: {app.images_completed} **************************")
    if app.images_processing_status.get("!!total!!",False): app.images_processing_status["!!total!!"]+= 1
    else: app.images_processing_status["!!total!!"] = 1
    del app.images_processing_status[image_name]
    return 0 #process sucessful

class Webserver(flask.Flask):
    def __init__(self,*args,**kwargs):
        scheduler_func = kwargs["scheduler_func"]
        function_to_process_image = kwargs["function_to_process_image"]
        queue_MAXSIZE = kwargs["queue_MAXSIZE"]
        del kwargs["function_to_process_image"], kwargs["scheduler_func"], kwargs["queue_MAXSIZE"]
        super(Webserver, self).__init__(*args, **kwargs)
        self.start_time = time.strftime("%d/%m/%Y %H:%M")
        self.queue_MAXSIZE = queue_MAXSIZE
        self.active_processing_threads = []
        self.images_processing_status = {}
        self.images_completed = {}
        self.images_toBe_processed = Queue(maxsize=queue_MAXSIZE)
        self.function_to_process_image = function_to_process_image
        self.scheduler_thread = threading.Thread(target=scheduler_func, args=(self,))


app = Webserver(__name__,
                  template_folder="./templates",
                  static_folder="./",
                  static_url_path='',
                  scheduler_func = run_scheduler,
                  function_to_process_image = function_to_process_image,
                  queue_MAXSIZE = 20,
                 )


### You define a bunch of views
@app.route("/",methods=["GET"])
def send_index_view():
    if not flask.current_app.scheduler_thread.isAlive():
        flask.current_app.scheduler_thread.start()
    return flask.render_template('index.html',queue_size = flask.current_app.images_toBe_processed.qsize(),
                                max_queue_size =flask.current_app.queue_MAXSIZE , being_processed=len(flask.current_app.active_processing_threads),
                                total=flask.current_app.images_processing_status.get("!!total!!",0), start_time=flask.current_app.start_time )

@app.route("/process_image",methods=["POST"])
def receive_imageProcessing_request_view():
    image_name = json.loads(request.data)["image_name"]
    if(flask.current_app.images_toBe_processed.qsize() >= flask.current_app.queue_MAXSIZE ):
        while(not flask.current_app.images_toBe_processed.empty()):
            flask.current_app.images_toBe_processed.get()
    requestedImage_status = {"name":image_name, "id":uuid.uuid1()}
    flask.current_app.images_toBe_processed.put(image_name)
    return flask.jsonify(requestedImage_status)

@app.route("/check_image_progress",methods=["POST"])
def check_image_progress():
    print(f'Current Image being processed: {flask.current_app.images_processing_status}')
    print(f'Current Images completed: {flask.current_app.images_completed}')
    image_name = json.loads(request.data)["image_name"]
    is_finished = flask.current_app.images_completed \
                                   .get(image_name,{"status":0,"file": ''})["status"]
    requestedImage_status = {
            "is_finished": is_finished,
            "progress":    flask.current_app.images_processing_status.get(image_name,"0")
            }
    return flask.jsonify(requestedImage_status) #images_processing_status[image_name]})

@app.route("/get_image",methods=["POST"])
def get_processed_image():
    image_name = json.loads(request.data)["image_name"]
    file_bytes = flask.current_app.images_completed[image_name]["file"] #open("binary_image.jpeg", 'rb').read()
    file_bytes = base64.b64encode(file_bytes.getvalue()).decode()
    flask.current_app.images_completed.clear()
    return flask.jsonify({image_name:file_bytes}) #images_processing_status[image_name]})

"./home/{username}/{flask_foldername}/templates/index.html"

<html>
<head>
</head>
<body>
    <h5> welcome to the index page, give some inputs and get a random RGB image back after some time</h5>
    <h5> Wait 10 seconds to be able to send an image request to the server </h5>
    <h5>When the page was loaded there were {{queue_size}} images on the queue to be processed, and {{being_processed}} images being processed</h5>
    <h5> The max size of the queue is {{max_queue_size}}, and it will be reseted when reaches it</h5>
    <h5>A total of {{total}} images were processed since the server was started at {{start_time}}</h5>
    <form>
      <label for="name">Image name:</label><br>
      <input type="text" id="name" name="name" value="ImageName" required><br>
    </form>
    <button onclick="send();" disabled>Send request to process image </button>
    <progress id="progressBar" value="0" max="100"></progress>
    <img style="display:block" />
    <script>
       window.image_name = "";
       window.requests = "";
       function send(){
           var formEl = document.getElementsByTagName("form")[0];
           var input = formEl.getElementsByTagName("input")[0];
           var RegEx = /^[a-zA-Z0-9]+$/;
           var Valid = RegEx.test(input.value);
           if(Valid){
               window.image_name = input.value;
               var xhttp = new XMLHttpRequest();
               xhttp.onload = function() {
                       result=JSON.parse(xhttp.response)
                       window.requests = setTimeout(check_image_progress, 3000);
               };
               xhttp.open("POST", "/process_image", true);
               xhttp.send(JSON.stringify({"image_name":input.value}));
               var buttonEl = document.getElementsByTagName("button")[0];
               buttonEl.disabled = true;
               buttonEl.innerHTML = "Image sent to process;only one image per session allowed";
           }
           else{
               alert("input not valid, only alphanumeric characters");
           }
        }

       function check_image_progress(){
           var xhttp = new XMLHttpRequest();
           xhttp.onload = function() {
                   result=JSON.parse(xhttp.response)
                   var progressBarEl = document.getElementsByTagName("progress")[0];
                   if(progressBarEl.value < result["progress"]){
                       progressBarEl.value=result["progress"];
                   } else {}
                   if(result["is_finished"] == true){
                       clearTimeout(window.requests);
                       window.requests = setTimeout(get_image,5);
                   }
                   else {
                       window.requests = setTimeout(check_image_progress, 3000);
                   }
           };
           xhttp.open("POST", "/check_image_progress", true);
           xhttp.send(JSON.stringify({"image_name":window.image_name}));
        }

       function get_image(){
           var xhttp = new XMLHttpRequest();
           xhttp.onload = function() {
                   result=JSON.parse(xhttp.response)
                   img_base64 = result[window.image_name];
                   var progressBarEl = document.getElementsByTagName("progress")[0];
                   progressBarEl.value=100;
                   clearTimeout(window.requests);
                   var imgEl = document.getElementsByTagName("img")[0];
                   console.log(result)
                   imgEl.src = 'data:image/jpeg;base64,'+img_base64;
           };
           xhttp.open("POST", "/get_image", true);
           xhttp.send(JSON.stringify({"image_name":window.image_name}));
        }
    setTimeout(function(){document.getElementsByTagName("button")[0].disabled=false;},100);
    function hexToBase64(str) {
        return btoa(String.fromCharCode.apply(null, str.replace(/\r|\n/g, "").replace(/([\da-fA-F]{2}) ?/g, "0x$1 ").replace(/ +$/, "").split(" ")));
    }
    </script>
</body>
</html>

(2) How to replicate, and create your own webapp

  1. go to your web app tab in web
  2. scroll down to find the source directory link
  3. click on the flask_app.py and include the flask_app.py code
  4. click on the templates directory, or create if doesn't exists
  5. click on the index.html file and include the index.html code
  6. go back to the web app tab and reload your app

walktrhough

(3) Main components of the app

Some details about pythonanywhere:

  1. Pythoanywhere run a wsgi to start your flask app, it will basically import your app from the flask_app.py and run it.

  2. by default wsgi is run in your /home/{username} folder and not in the /home/{username}/{flask_folder}, you can change this if you want.

  3. Pythonanywhere is single-threaded so you can't rely on sending jobs to background.

The main components to watch out for in the backend:

  • 1) Threads, Flask will be in the main Thread run by wsgi and we will run a child thread scheduler that will keep track of the Queue and schedule the next image to be processed.

  • 2) Flask class: app, the component which handles user requests and send processing requests to the Queue

  • 3) Queue, a Queue that stores in order the request from users to process images

  • 4) Scheduler, The component that decides if a new function process_image call can be run and if yes. It needs to be run in an independent Thread than flask.

  • 5) Encapsulate all those in a custom class Webserver to be able to easily access then (pythonanywhere uses wsgi which makes keeping track of variables created locally hard)

So taking look in the big picture of the code

#lot of imports
+-- 14 lines: from queue import Queue-----------------------------------------------------------------------------------------

# this function will check periodically if there's no images being processed at the moment. 
# if no images are being processed check in the queue if there's more images to be processd
# and start the first one in the queue 
def run_scheduler(app):
+-- 12 lines: sleep_time = 5 -------------------------------------------------------------------------------------------------

# this function do the math and creates an random RGB image in the end.
def function_to_process_image(image_name, app):
+-- 21 lines: {---------------------------------------------------------------------------------------------------------------

# This class encapsulates all the data structures("state") from our application
# in order to easily access the progress and images information 
class Webserver(flask.Flask):
    def __init__(self,*args,**kwargs):
+-- 13 lines: scheduler_func = kwargs["scheduler_func"]-----------------------------------------------------------------------

# Here we're instatiating the class
app = Webserver(__name__,
+--  5 lines: template_folder="./templates",----------------------------------------------------------------------------------
                  queue_MAXSIZE = 20,
                 )


### You define a bunch of views
+-- 39 lines: @app.route("/",methods=["GET"]) --------------------------------------------------------------------------------

the main components of the frontend:

  1. send function which is triggered when user clicks the send request to process image button
  2. check_progress function which is triggered by send function to recurrently request the check_progress view in flask to get info about progress. When processing is over we remove the recurrence.
  3. get_image function which is triggered by check_progress when processing is over ('is_finished' = 1)

big picture of the frontend:

<html>
<head>
</head>
<body>
    <!-- JUST THE INITIAL HTML elements -->
+-- 12 lines: <h5> welcome to the index page, give some inputs and get a random RGB image back after some time</h5>-----------


    <script>
       window.image_name = "";
       window.requests = "";
       function send(){
           // SEND image process request when click button and set a timer to call periodically check_image_process
+-- 20 lines: var formEl = document.getElementsByTagName("form")[0];----------------------------------------------------------
        }

       function check_image_progress(){
           // SEND a request to get processing status for a certain image_name
+-- 18 lines: var xhttp = new XMLHttpRequest();-------------------------------------------------------------------------------
        }

       function get_image(){
           // SEND a request to get the image when image_status 'is_processed' = 1
+--- 13 lines: var xhttp = new XMLHttpRequest();------------------------------------------------------------------------------
        }
    setTimeout(function(){document.getElementsByTagName("button")[0].disabled=false;},100);
    </script>
</body>
</html>
  • This looks promising. I am currently playing with it locally on a PC, playing around with changing some of the numbers. One thing I found is that I can't seem to stop it! Normally when I run a flask ap locally (by typing python app.py) I can bring it to a halt with ctrl-C but with this code ctrl-C has no affect and i have to close the terminal and start a new one! If that's a feature and not a bug then I'm not worried :-) – Mick Apr 25 '20 at 14:19
  • @Mick, it does stop with Ctrl-C when I press it. the idea is both flask and scheduler will be running continously, if flask doesn't get any requests then you will just see the scheduer print endlessly – Bernardo stearns reisen Apr 25 '20 at 14:25
  • could you specify how you're running it in your local ? I am running locally simply by adding `app.run(host="0.0.0.0", port=5000)` to the end of the script `flask_app.py` – Bernardo stearns reisen Apr 25 '20 at 14:26
  • you mention you're running `python app.py` , are you adding anything to the code? – Bernardo stearns reisen Apr 25 '20 at 14:28
  • I stuck if __name__ == '__main__': app.run(debug=True) on the end of it... It all looks like it's working. – Mick Apr 25 '20 at 14:32
  • @Mick I tried ctrl-C with `name == 'main': app.run(debug=True)` in the end of my file and it worked – Bernardo stearns reisen Apr 25 '20 at 14:36
  • 1
    It's not so strange for processes to be inconsistent in their response to ctrl-C. It's not an issue - I can restart another way... and it won't be an issue on pythonanywhere. – Mick Apr 25 '20 at 14:40
  • 1
    I strongly suspect that it's the right technique, I've had up up rand running on pythonanywhere and I'm currently experimenting with it, trying to fully understand every part (learning some javascript), adding diagnotics, changing timeouts and delays, making modifications, seeing what happens when you have to users trying to use it at the same time, all that sort of thing. – Mick Apr 26 '20 at 13:56
  • sounds good, we could talk on the chat if you want to discuss anything. You should have noticed that the is_processing variable is always setted to zero :) I forgot to increment it – Bernardo stearns reisen Apr 26 '20 at 13:58
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/212551/discussion-between-mick-and-bernardo-stearns-reisen). – Mick Apr 26 '20 at 13:58
  • 1
    I suspect the ctrl-C issue probably relates to this: https://stackoverflow.com/questions/11815947/cannot-kill-python-script-with-ctrl-c – Mick May 01 '20 at 18:48
4

You can create a simple stream using Flask stream_with_context and then use yield to send some data about the progress to the client using JSON format. You'll read that data using XMLHttpRequest: progress event and display the progress to the client or update an element with the image when the script is done.

Python Flask code:

Please read inline comments

import time
from flask import Flask, stream_with_context, request, Response
app = Flask(__name__, static_url_path='',static_folder='static')

@app.route('/')
def hello_world():
    return app.send_static_file('index.html')

@app.route('/compute-image')
def compute_image():
    def generate():
        huge_number = 100000
        progress = 0

        # Start by sending total JSON value to the client stream
        yield "{\"total\": %d}" % huge_number

        for i in range(huge_number):
            # If there is a 100 step progress, send progress JSON value to the client stream
            if progress >= 100:
                progress = 0
                yield "{\"progress\": %d}" % (i)
            # Else increment progress by 1 and sleep a bit for demonstration
            else:
                progress = progress + 1
                time.sleep(0.0005)

        # Send the generated image location to the client when we're done
        yield "{\"img_url\": \"/some/generated/image.png\"}"

    # Return the stream context with out generator function
    return Response(stream_with_context(generate()))

Client Javascript code:

Please read inline comments

// Compute button
const btnEl = document.getElementById('compute');

// Progress label
const progEl = document.getElementById('progress');

// Time elapsed label
const timeEl = document.getElementById('time');

btnEl.addEventListener('click', function(e) {
  btnEl.disabled = true;
  progEl.textContent = '';
  timeEl.textContent = '';

  // Track the AJAX response buffer length
  let prevBufferEnd = 0;

  // Save the total (huge_number)
  let total = 0;

  // Save the time we've started
  const timeStart = new Date;

  const xhr = new XMLHttpRequest();

  xhr.addEventListener('progress', function(e) {
    // Get the current progress JSON data
    let currentProgress = e.currentTarget.responseText.substring(prevBufferEnd, e.currentTarget.responseText.length);
    prevBufferEnd = e.currentTarget.responseText.length;

    // Parse JSON data
    const respData = JSON.parse(currentProgress);

    if (respData) {
      // If there is a total, save to the total variable
      if (respData['total']) {
        total = respData.total;
      // If there is a progress, display progress to the client
      } else if (respData['progress']) {
        const { progress } = respData;
        const percent = (progress / total) * 100;

        progEl.textContent = `${respData['progress']} of ${total} (${percent.toFixed(1)}%)`;

        const timeSpan = (new Date) - timeStart;

        timeEl.textContent = `${timeSpan / 1000} s`;
      // Elese if there is an img_url, we've finished
      } else if (respData['img_url']) {
        progEl.textContent = `Done! Displaying image: "${respData['img_url']}"`;
      }
    }
  });

  xhr.addEventListener('loadend', function(e) {
    btnEl.disabled = false;
  });

  xhr.open("GET", '/compute-image');
  xhr.send();
});

Demo client screen capture:

Flask AJAX Demo client screen capture

You can find the demo project in this Github repository: https://github.com/clytras/py-flask-ajax-progress

Christos Lytras
  • 36,310
  • 4
  • 80
  • 113
  • It's taken me an embarrassingly long time to make 14 lines of HTML to go with your demo code - but now it's working... It looks very promising. Tomorrow I'll see if I can make the real (non-pseudo) code work. – Mick Apr 21 '20 at 18:25
  • @Mick this is not pseudocode and don't worry, I'll upload it to a Github repo so you can clone it and test it out. I'll update my answer ASAP. – Christos Lytras Apr 21 '20 at 20:20
  • I didn't mean to imply that your code was pseudocode - its just that it's currently wrapping *my* pseudo code. Your code is fully working I can see. I just want to check that no problems crop up when I cut and paste your code into my "real" code. I have no reason to expect any problems. – Mick Apr 21 '20 at 21:49
  • I have updated my answer with the Github repository so you can clone and run it. Please let me know if you have any issues running and implementing it. – Christos Lytras Apr 21 '20 at 22:31
  • In my code I had been using render_template() - which I think pre-processes the {% %} stuff? I noticed you used send_static_file() I guessed that I could just swap out your send_static_file() for render_template() but that seems to have broken things... I get the feeling that send_static_file() does not preprocess the {% %} stuff. Is this a big spanner in the works? – Mick Apr 21 '20 at 23:00
  • I have updated the Github repository project to use a template under `/compute` URL. Please check it out. What exact issues do you have using `render_template`? – Christos Lytras Apr 21 '20 at 23:22
  • Page appears fine to start, then I click "Ajax compute" then I get: SyntaxError: JSON.parse: unexpected non-whitespace character after JSON data at line 1 column 16 of the JSON data. BTW, I had to put if __name__ == "__main__": app.run(debug=True) at the end of app.py – Mick Apr 22 '20 at 00:06
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/212247/discussion-between-christos-lytras-and-mick). – Christos Lytras Apr 22 '20 at 09:13