GUI for Python using React and distributed with pip
Tags: django, python, webdev, react
Published: 2021-05-20 20:01:06

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:

{% youtube yC8WtCjTNSs %}

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:
```jsx
// 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}
    />
}
```

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

```jsx
// 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:

```py
# 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
```

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

```py
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)
```

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:

```py
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.


### Does your Django codebase have room for improvement?

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