GUI for Python using React and distributed with pip

codereviewdoctor

Code Review Doctor

Posted on May 20, 2021

GUI for Python using React and distributed with pip

Django Doctor analyses codebases and suggests improvements. It used to be SaaS only, but today we released it as an offline command line tool. This blog post explains how we used React to make a nice UI for our Python command line interface:

For our command line tool we wanted a nice UI. We use React for our website so we considered how to pass data from Python to React and back again, in a way that can easily be distributed via pip install django-doctor. We didn't use Django or Flask as we wanted an apple, not a gorilla holding an apple.

The code

The following React Component is a form wizard that accepts a list of items and allows the user to choose a subset, then that subset of items is posted to the server:

// App.jsx
export default function({ messages }) {
  const [httpState, setHttpState] = React.useState({
    isInProgress: false,
    isComplete: false,
  })

  function handleSave(selectedMessages) {
    setHttpState({isInProgress: true, isComplete: false })
    fetch('/done/', {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(selectedMessages)
    }).then(result => {
      setHttpState({isInProgress: false, isComplete: true })
    }).catch(result => {
      setHttpState({isInProgress: false, isComplete: false })
    })
  }
  if (httpState.isComplete) {
    return <Done />
  } else {
  return (
    <Wizard
      handleSave={handleSave}
      messages={messages}
      httpState={httpState}
    />
}
Enter fullscreen mode Exit fullscreen mode

We can pass into the component some data provided by a Python script by doing the following:

// index.js
import React from 'react';
import { render } from "react-dom";

import './App.css';
import './App.jsx';


const contextElement = document.getElementById('context-messages')
const messages = JSON.parse(contextElement.textContent)
render(<App messages={messages} />, rootElement);
Enter fullscreen mode Exit fullscreen mode

So index.js expects the HTML page it's being served from to contain an element with ID context-messages to contain some JSON serialized data. Now is where the Python comes in. We serve the HTML file using features provided by Python's build in wsgiref library:

# wsgi.py

import json
import mimetypes
import pathlib
import threading
from wsgiref.simple_server import make_server
from wsgiref.util import FileWrapper

# a folder containing the built React app, which we trick python into working with by adding an __init__.py to it
import django_doctor.wizard


static_dir = pathlib.Path(django_doctor.wizard.__path__[0])

with open(static_dir / 'index.html', 'r') as f:
    home_template_body = f.read()


def home_handler(environ, respond, app):
    # exposing data to the HTML template using an approach inspired by https://docs.djangoproject.com/en/3.1/ref/templates/builtins/#json-script
    messages = json.dumps(app.messages)
    body = home_template_body.replace(
        '<head>',
        f'<head><script id="context-messages" type="application/json">{messages}</script>'
    )
    body = response_body.encode('utf-8')
    respond('200 OK', [('Content-Type', 'text/html'), ('Content-Length', str(len(body)))])
    return [body]


def static_handler(environ, respond, app):
    # serve the css/js/png/etc files
    content_type = mimetypes.guess_type(environ['PATH_INFO'])[0]
    path = static_dir / environ['PATH_INFO'][1:]
    respond('200 OK', [('Content-Type', content_type)])
    return FileWrapper(open(path, "rb"))


def submit_handler(environ, respond, app):
    body_size = int(environ.get('CONTENT_LENGTH', 0))
    request_body = environ['wsgi.input'].read(body_size)
    selected_messages = json.loads(request_body)
    # TODO: do something with selected_messages
    respond('200 OK', [('Content-Type', 'text/plain')])
    # make the server kill itself after the response is sent
    threading.Timer(0.5, app.server.shutdown).start()
    return [b'']


class Application:
    def __init__(self, messages):
        self.messages = messages

    def __call__(self, environ, respond):
        if environ.get('PATH_INFO') == '/':
            return home_handler(environ=environ, respond=respond, app=self)
        elif environ.get('PATH_INFO') == '/done/':
            return submit_handler(environ=environ, respond=respond, app=self)
        elif environ.get('PATH_INFO').startwith('/static/'):
            return static_handler(environ=environ, respond=respond)



def create(messages):
    app = Application(messages=messages)
    server = make_server(host='localhost', port='9000', app=app)
    app.server = server
    return server
Enter fullscreen mode Exit fullscreen mode

Then we can create some command line tool that calls wsgi.create:

import argparse

from django_doctor import check_codebase, wsgi


parser = argparse.ArgumentParser(prog='Django Doctor')

parser.add_argument('-d', '--directory', default='.')


def handle(argv=sys.argv[1:]):
    options = parser.parse_args(argv)
    messages = check_codebase(project_root=options.directory)
    wsgi.create(messages=messages)
Enter fullscreen mode Exit fullscreen mode

So now we have bi-directional communication with react and python:

  1. A python command line script that runs check_codebase then passes messages to the wsgi app
  2. A wsgi app that renders a HTML file containing messages, and (not shown) a <script> tag that serves the build react js
  3. A React app that hydrates the json and then passes it to form wizard, then ultimately posts the selected items back to /done/.
  4. a wsgi handler that sees data posted to /done/ and does soemthing with it

Pretty cool. To make it cooler we can replace the http post request and rendering of html with a websocket. Less hacky. Maybe we will eventually use that at Django Doctor.

distributing via pip install

setup.py is great at distributing Python files, but for this to work we need to make setup.py create a distribution containing Python files and .js and .png and .html etc.

We do that by copying the build react app to ./wizard, add __init__.py to it, then write the setup.py like so:

setup(
    name="django_doctor",
    url="https://django.doctor",
    packages=find_packages(include=['django_doctor.wizard',]),
    include_package_data=True,
    classifiers=[
        "Development Status :: 5 - Production/Stable",
        "Environment :: Web Environment",
        "Framework :: Django",
        "Intended Audience :: Developers",
        "Natural Language :: English",
        "Operating System :: OS Independent",
        "Programming Language :: Python",
        "Topic :: Software Development :: Libraries :: Python Modules",
    ],
)

Enter fullscreen mode Exit fullscreen mode

The meat is in packages - making sure wizard package is included, and include_package_data to make sure the non python files are distributed too.

Does your Django codebase have room for improvement?

Use our command line interface tool to check. pip install django-doctor then django_doctor fix.

💖 💪 🙅 🚩
codereviewdoctor
Code Review Doctor

Posted on May 20, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related