Create a GUI for Python using React

Code Review Doctor
4 min readMay 20, 2021

--

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}
/>
}
// 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);

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.pyimport 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

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

import argparsefrom django_doctor import check_codebase, wsgiparser = 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)

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](https://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",
],
)

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.

### Try it yourself

Use our command line interface tool to check your Django code for improvements. Run `pip install django-doctor` then `django_doctor fix`.

--

--