naritoブログ

このブログはPython/DjangoとBootstrap4で作成されました
Githubのソースコード

Djangoで、Pythonエディタを作る①

約425日前 2017年3月26日23:45
プログラミング関連
Bootstrap4 Django subprocessモジュール Python
改良版は以下です。
Djangoで、Pythonエディタを作る②
https://torina.top/detail/345/

Djangoで、Pythonエディタを作る③
https://torina.top/detail/347/

DjangoでシンプルなPythonエディタを作ります。
python manage.py runserverとコマンドを打ち、ローカル環境で動かすことを想定しています。
Windows以外だと、ちょっと手直しが必要になるかも...

Kivyで、シンプルなエディタ②
https://torina.top/detail/306/
の、Djangoバージョンという感じです。

Django1.10
Python3.6
で動かしています。Djangoのバージョンは多少前後しても大丈夫だとは思いますが、
Pythonは3.6のf-stringsやsubprocessの新しい機能を使っているため、気を付けてください。

プロジェクト名は「project」
アプリケーションは1つで、「dteditor」
です。(django-torina-editor)

今回、テキストエリアに行番号を表示するため、bcralnit.jsを使用しています。
https://github.com/bachors/bcralnit.js


見た目



まずはトップ画面。画面左側はファイルの選択エリア、右側がコード入力エリア、下は出力エリアです。


ファイルを選択すると、このようにファイルの内容がコードエリアに表示されます。
行番号が表示されますが、シンタックスハイライト機能は今回見送りました...


Runボタンを押すと、ちゃんと実行され下に出力が表示されます。


Saveを押すと、ちゃんと保存されます。
実行するとエラーになるように内容を変えました。(シングルクォートをはずした)


実行すると、ちゃんとエラーが表示されますね。


ファイルを選択すると画面上の入力欄にファイル名が自動で入るのですが、ここの名前を変更するとその名前でファイルを保存できます。
hello.pyに変更して...


Saveを押すと、ちゃんとファイル選択エリアにhello.pyがふえました。


上のファイル名欄を空欄にしてSaveを押すと、このように怒られますが...


ちょっとしたスクリプトを試したいときもあるはずです。ファイル名が空欄でも、Runで実行はできるようにしました。


エディタ自体にタブ機能はないのですが、よく考えるとブラウザにタブという概念が元々ありました。
左のファイルをCtrl+クリックで、新しいタブにその内容が開かれます。


project/settings.py


INSTALLED_APPSに'dteditor'を足したぐらいです。
"""
Django settings for project project.

Generated by 'django-admin startproject' using Django 1.10.6.

For more information on this file, see
https://docs.djangoproject.com/en/1.10/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.10/ref/settings/
"""

import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'h632h%jbvdzl3*$*o4+1!v_l&uc)&7oj83i=b&9le*6r^ld#g^'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'dteditor',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'project.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'project.wsgi.application'


# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/

STATIC_URL = '/static/'


project/urls.py


from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include('dteditor.urls', namespace='dteditor')),
]


dteditor/urls.py


from django.conf.urls import url
from . import views
 
urlpatterns = [
    url(r'^$', views.Home.as_view(), name='home'),
]


admin.pyやmodels.pyは、今回使用していません。

dteditor/forms.py


from django import forms


class EditorForm(forms.Form):
    code = forms.CharField(
        widget=forms.Textarea(attrs={
            'class': 'w-100 h-100'
        }),
        required=False,
    )
    file_name = forms.CharField(
        required=False,
    )


dteditor/views.py


from contextlib import contextmanager
import io
import os
import subprocess
import sys
import traceback
from django.conf import settings
from django.views import generic
from .forms import EditorForm


@contextmanager
def stdoutIO():
    """一時的にstdoutを変更する"""

    old = sys.stdout
    sys.stdout = io.StringIO()
    yield sys.stdout
    sys.stdout = old


def execute_python_exec(code):
    """pythonコードをexecで評価し、出力を返す

    引数:
        code: Pythonコードの文字列

    返り値:
        出力とエラーの文字列
    """

    output = ''
    with stdoutIO() as stdout:
        error = ''
        try:
            exec(code)
        except:
            error = traceback.format_exc()
        finally:
            output = stdout.getvalue() + error
    return output


def execute_python_subprocess(file_path, python_path=sys.executable, timeout=15):
    """python file_path を行い、出力を返す

    引数:
        file_path: pythonファイルのパス
        python_path: pythonインタプリタのパス。デフォルトはこのDjangoを実行しているPythonインタプリタ
        timeout: プログラムの実行を何秒まで待つか。デフォルトは15秒

    返り値:
        出力とエラーの文字列
    """

    cmd = f'{python_path} {file_path}'
    ret = subprocess.run(
        cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        timeout=timeout, encoding='cp932')
    return ret.stdout


class Home(generic.FormView):
    template_name = 'dteditor/home.html'
    form_class = EditorForm

    def get_current_dirpath(self):
        """エディタの、現在のディレクトリパスを取得する"""

        # パラメータがなければ、manage.py があるディレクトリを返す
        current_path = self.request.GET.get('dir_path', settings.BASE_DIR)

        # ?dir_path= のような場合
        if not current_path:
            current_path = settings.BASE_DIR

        # もしもファイルだった場合は、そのディレクトリのパスに
        if os.path.isfile(current_path):
            current_path = os.path.dirname(current_path)

        return current_path

    def get_context_data(self, **kwargs):
        """contextの取得。アクセスされれば常に呼び出される"""

        # エディタの、現在表示すべきディレクトリパスを取得
        current_path = self.get_current_dirpath()

        # ディレクトリや全てのファイルの名前が入る
        files_and_dirs = os.listdir(current_path)

        # dirnameで前のフォルダを表せます
        before_dir = ('前のフォルダ', os.path.dirname(current_path))

        # ファイル一覧とディレクトリ一覧の作成処理
        files = []
        dirs = [before_dir]
        for path in files_and_dirs:
            path = os.path.join(current_path, path)
            basename = os.path.basename(path)
            if os.path.isdir(path):
                dirs.append((basename, path))
            else:
                files.append((basename, path))

        # contextのアップデート。{{ current_path }}等が使えるようになる
        extra_context = {
            'current_path': current_path,
            'dirs': dirs,
            'files': files,
        }
        kwargs.update(extra_context)
        return super().get_context_data(**kwargs)

    def get_initial(self):
        """formの初期値の取得

        &read_path=... パラメータがある場合は、そのファイルパスをopenで開き
        エディタのコード入力欄へ移す。
        このread_pathパラメータが来るのは、画面左側の一覧からファイルをクリックした時だけ
        """

        try:
            path = self.request.GET['read_path']
        except KeyError:
            path = ''
            code = ''
        else:
            code = open(path, 'r', encoding='utf-8').read()

        initial = {
            'code': code,
            'file_name': os.path.basename(path),
        }
        return initial

    def run(self, form):
        """プログラムを実行する"""

        code = form.cleaned_data['code']

        # プログラムが空欄の場合は、実行しない
        if not code:
            output = '空欄です'
        else:
            current_path = self.get_current_dirpath()
            file_name = form.cleaned_data['file_name']
            file_path = os.path.join(current_path, file_name)

            # 何かファイルを開いているときは、pyton file_path を実行
            if file_name and os.path.isfile(file_path):
                output = execute_python_subprocess(file_path)

            # ファイルを開いてなければ、exec(code)を実行
            else:
                output = execute_python_exec(code)

        context = self.get_context_data(form=form, output=output)
        return self.render_to_response(context)

    def save(self, form):
        """プログラムの新規保存・上書き保存を行う"""

        output = ''
        file_name = form.cleaned_data['file_name']

        # ファイルを新規作成か、上書き保存の処理
        if file_name:
            current_path = self.get_current_dirpath()
            file_path = os.path.join(current_path, file_name)
            code = form.cleaned_data['code']
            with open(file_path, 'w') as file:
                file.write(code)
                output = f'{file_path}を保存しました'
        else:
            output = 'ファイル名を入力するか、 何かファイルを開いてください'
        context = self.get_context_data(form=form, output=output)
        return self.render_to_response(context)

    def form_valid(self, form):
        """Run、又はSaveを押した際に呼び出される"""

        if 'run' in self.request.POST:
            return self.run(form)
        elif 'save' in self.request.POST:
            return self.save(form)



dteditor/templates/dteditor/base.html


{% load static %}
<!DOCTYPE html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="shortcut icon" href="{% static 'dteditor/favicon.ico' %}">
 
    <title>{% block title %}{% endblock %}</title>

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">

    <!-- マイ CSS -->
    <link rel="stylesheet" href="{% static 'dteditor/css/base.css'  %}">

  </head>
  <body>
    {% block content %}{% endblock %}
 
    <!-- jQuery first, then Tether, then Bootstrap JS. -->
    <script src="https://code.jquery.com/jquery-3.1.1.slim.min.js" integrity="sha384-A7FZj7v+d/sdmMqp/nOQwliLvUsJfDHW+k9Omg/a/EheAdgtzNs3hpfag6Ed950n" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js" integrity="sha384-vBWWzlZJ8ea9aCX4pEW3rVHjgjt7zpkNpZk+02D9phzyeVkE+jo0ieGizqPLForn" crossorigin="anonymous"></script>

    <!-- bcralnit.js テキストエリアに行番号を -->
    <script src="{% static 'dteditor/bcralnit/js/bcralnit.js'  %}"></script>
    <script>
        $("#id_code").bcralnit();
    </script>

  </body>
</html>



dteditor/templates/dteditor/home.html


{% extends "dteditor/base.html" %}
{% block title %}
  {{ form.file_name.value }}
{% endblock %}
{% block content %}
<form action='{% url "dteditor:home" %}?dir_path={{ current_path }}' method="POST">

<!-- Run、Saveなどのボタンエリア -->
<div id="menu-wrapper">
  <div class="container-fluid">
    <span class="text-white">
      {{ current_path }}\{{ form.file_name }}
    </span>
    <button type="submit" name="save" class="btn btn-secondary btn-lg">
      Save
    </button>
    <button type="submit" name="run" class="btn btn-secondary btn-lg float-right">
      Run
    </button>
  </div>
</div>

<!-- ファイル選択とコード入力欄 -->
<div class="row" id="main-wrapper">

  <!-- ファイル選択エリア -->
  <div class="col-3 bg-faded scroll">
    <div class="container-fluid">
    <p>カレントパス:{{ current_path }}</p>
    <p>ディレクトリ一覧</p>
    {% for dir in dirs %}
      <p>
        <a href="{% url 'dteditor:home' %}?dir_path={{ dir.1 }}">{{ dir.0 }}</a>
      </p>
    {% endfor %}

    <p>ファイル一覧</p>
    {% for file in files %}
      <p>
        <a href="{% url 'dteditor:home' %}?dir_path={{ current_path }}&read_path={{ file.1 }}">{{ file.0 }}</a>
      </p>
    {% endfor %}
    </div>
  </div>

  <!-- コード入力エリア -->
  <div class="col-9 bg-faded">
    {{ form.code }}
  </div>

</div>

<!-- 出力表示エリア -->
<div id="output-wrapper">
  <textarea class="bg-faded w-100 h-100" readonly>{{ output }}</textarea>
</div>

{% csrf_token %}
</form>

{% endblock %}


dteditor/static/dteditor/css/base.css


body {
	background-color: black;
}

html, body, form {
	height: 100%;
}

#menu-wrapper {
	height: 5%;
	width: 100%;
}

#main-wrapper {
	height: 70%;
	width: 100%;
}

#output-wrapper {
	height: 25%;
	width: 100%;
}

.scroll {
	overflow: scroll;
}