Code Review Doctor
Posted on May 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}
/>
}
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);
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
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)
So now we have bi-directional communication with react and python:
- A python command line script that runs
check_codebase
then passesmessages
to the wsgi app - A wsgi app that renders a HTML file containing
messages
, and (not shown) a<script>
tag that serves the build react js - A React app that hydrates the json and then passes it to form wizard, then ultimately posts the selected items back to
/done/
. - 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",
],
)
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
.
Posted on May 20, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.