naritoブログ

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

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

プログラミング関連 Bootstrap4 Django Python 約48日前
2017年4月5日4:11
Github
https://github.com/naritotakizawa/django-torina-editor2

Djangoで、Pythonエディタを作る②
https://torina.top/detail/345/
の、改良版です。

DjangoでPythonエディタを作ります。
python manage.py runserverとコマンドを打ち、ローカル環境で動かすことを想定しています。
Windows以外だと、現在は動かないです。

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

プロジェクト名は「project」
アプリケーション名は「dteditor2」
です。

見た目



127.0.0.1:8000へアクセスすると、こんな感じ



画面左側は、ディレクトリとファイルの一覧です。ディレクトリクリックで移動、ファイルクリックで開きます。
「..」は、前のディレクトリへ移動します。



ファイルを開くと、このようになります。Aceエディタを使用しています。



画面右側は、色々と確認ができるエリアです。

Opening File:は、現在開いているファイルのパス
Opening File Nameは、そのファイルのファイル名部分
Ace Editor type:は、Aceエディタが何のモードになっているかです。
Current Dirは、現在の基準となるディレクトリです。
画面左側の一覧は、このCurrent Dirを基準に作成されますし、saveコマンドなどのファイル保存処理もこのCurrent Dirを基準に保存されます。


画面下側は、cmd.exeを操作できます。出力を表示できるほか、コマンドの入力も可能です


このcmd.exeの操作は、
Djangoで、コマンドプロンプトを操作する
https://torina.top/detail/343/
https://github.com/naritotakizawa/cmdpr
を利用しています。まだ納得の行く出来ではないですが、何とか動きます。



DOSコマンドのほか、独自のコマンドをいくつか利用できます。例えばファイルの保存を行うsaveコマンド等。
今回はボタンやメニューを極力減らし、このコマンドで殆どの操作を行えるようにしています。
この独自コマンドは、pythonの関数として定義しており、割と簡単に作成できます。






ディレクトリの移動はcdなんかだとしんどいので、画面左の一覧にてディレクトリクリックで移動しつつ、「current」コマンドでコマンドプロンプトもその階層に移動できます。




もちろん、activateやdeactivateで仮想環境の切り替えもできます。あくまでコマンドプロンプトを操作しています。



pythonファイルの実行もできます。




save ファイル名で、新規保存、saveだけだと上書き保存になります。



「python」やDjangoの「python manage.py runserver」、あるキー入力がされるまで無限ループとなるようなプログラムは、まだ上手く動作しません。苦肉の策ですが、この場合はstart を先頭につけることで、cmd.exeを新しく立ち上げ実行できます。



manage.py


今回、manage.pyを少し変更しています。

#!/usr/bin/env python
import sys
from django.conf import settings
from project import settings as mysettings

if __name__ == "__main__":
    mysettings = {k: v for k, v in mysettings.__dict__.items() if k.isupper()}
    settings.configure(**mysettings)
    try:
        from django.core.management import execute_from_command_line
    except ImportError:
        # The above import may fail for some other reason. Ensure that the
        # issue is really that Django is missing to avoid masking other
        # exceptions on Python 2.
        try:
            import django
        except ImportError:
            raise ImportError(
                "Couldn't import Django. Are you sure it's installed and "
                "available on your PYTHONPATH environment variable? Did you "
                "forget to activate a virtual environment?"
            )
        raise
    execute_from_command_line(sys.argv)


変更部分は下記の部分です。
結論から言えば、環境変数 DJANGO_SETTINGS_MODULEを使わず、django.conf.settings.configure()を利用するようにした、ということです。
# 元々のmanage.py
import os
import sys

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")

↓↓

import os
import sys
from django.conf import settings
from project import settings as mysettings

if __name__ == "__main__":
    mysettings = {k: v for k, v in mysettings.__dict__.items() if k.isupper()}
    settings.configure(**mysettings)



今回のエディタにはcmd.exeが内臓されており、色々と操作ができます。
問題は、このcmd.exe上でDjangoプロジェクトをrunserverする可能性があることです。
既にこのエディタを動かす際にDJANGO_SETTINGS_MODULEを設定してしまっています。エディタ内部のcmd.exeは、この設定を受け継いでいる状態です。
実際、この状態のまま内部のcmd.exeで他のdjangoプロジェクトをrunserverすると、以下のエラーが起きます。



Djangoを動かす際に、どんな設定でDjangoを動かすかを教える必要があります。
その方法の一つが、環境変数 DJANGO_SETTINGS_MODULEに設定ファイル(settings.py)の在処を書く、です。("project.settings")

他にも方法があり、これはdjango.conf.settings.configure()を使って手動で設定する方法です。
こちらなら環境変数を利用しないので、エラーにはならなそうです。

その設定が、以下です。
設定自体はsettings.pyに書いていますし、それを使わない手はありませんので、settings.pyから各変数を読み込むようにしました。
import os
import sys
from django.conf import settings
from project import settings as mysettings

if __name__ == "__main__":
    mysettings = {k: v for k, v in mysettings.__dict__.items() if k.isupper()}
    settings.configure(**mysettings)



少しづつ見ていきます。以下のコードは
    for key, value in mysettings.__dict__.items():
        print(key, value)



このように表示されます。
BASE_DIR C:\MyMercurial\python\django\django-torina-editor2\django-torina-editor2
...
...
...
LANGUAGE_CODE ja
TIME_ZONE Asia/Tokyo
USE_I18N True
USE_L10N True
USE_TZ True
STATIC_URL /static/


__builtins__だとか、そういった不必要な属性まで拾ってしまうため、それらは覗く必要があります。
必要なものは全て大文字で書かれているため、判断は簡単です。isupper()で行けます。
    for key, value in mysettings.__dict__.items():
        if key.isupper():
            print(key, value)


それを辞書内包表記にしたのが、前のコードです。
mysettings = {k: v for k, v in mysettings.__dict__.items() if k.isupper()}



configureには、debug=True, などのように渡す必要があります。
なので、**mysettingsとすることで全てキーワード引数で渡せますね。
settings.configure(**mysettings)



project/settings.py


INSTALLED_APPSにdteditor2を、MIDDLEWAREに'dteditor2.middleware.EditorMiddleware', を追加します。
一番下にユーザー定義用の変数が2つあります。
"""
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 = 'ewg73ezm(8s4b=r&om3bwh94ekojl%t^=w=32j8b3xf08-m+ek'

# 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',
    'dteditor2',  # 追加
]

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',
    'dteditor2.middleware.EditorMiddleware',  # 追加
]

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/'

# 以下の2変数は、dteditor2/editor.pyで定義されていますが、settings.pyで上書きが可能です。
# 上書きする場合はコメントアウトを外してください。
"""
DEFAULT_ACE_TYPE = 'python'

FILE_TYPE = {
    '.java': 'java',
}
"""


DEFAULT_ACE_TYPEという変数は、Aceのモード名のデフォルトです。
新しいファイルを開いたときや、登録されていない拡張子を開いたときのAceエディタのモードになります。
dteditor2/editor.pyでのデフォルト設定は、plain_textです。
settings.pyで何か定義すれば、それが優先されます。
DEFAULT_ACE_TYPE = 'python'



FILE_TYPEは、拡張子に対応するAceのモード名です。
dteditor2/editor.pyでは、以下の拡張子に対応しています。
これを更に上書き・追加したい場合に、settings.pyで定義してください。
(辞書に対して、updateメソッドが呼ばれます)
FILE_TYPE = {
    '.py': 'python',
    '.html': 'html',
    '.css': 'css',
    '.js': 'javascript',
    '.rst': 'rst',
    '.json': 'json',
    '.xml': 'xml',
}



project/urls.py


includeするだけ!
from django.conf.urls import url, include
from django.contrib import admin

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



admin.py、models.py、あたりは今回使っていません。
pythonファイルなんかの差分やバックアップを取りたいなら、そういったモデルを作成することになるでしょう。

dteditor2/urls.py


今回、viewは一つだけです。
from django.conf.urls import url
from . import views
 
urlpatterns = [
    url(r'^$', views.Home.as_view(), name='home'),
]



dteditor2/forms.py


フォームもシンプルなのが1つです。コード入力欄と、コマンド入力欄
from django import forms


class EditorForm(forms.Form):

    # コード入力欄
    code = forms.CharField(
        widget=forms.Textarea,
        required=False,
    )

    # コマンド入力欄
    cmd = forms.CharField(
        required=False,
    )



dteditor2/middleware.py


ミドルウェアを利用しています。
今回のエディタでは、エディタの基準ディレクトリと、エディタが開いているファイル、という2つの情報をなんやかんやしています。
それぞれcurrent_dirとopening_fileという二つのGETパラメータなのですが、この情報を様々なところから参照する機会が多くなりました。
そこで、ミドルウェアを使い、viewに届く前でrequest.editorとして、必要なデータが詰まったオブジェクトを格納することにしました。
from django.utils.deprecation import MiddlewareMixin
from .editor import create_editor_info


class EditorMiddleware(MiddlewareMixin):
    """Viewの手前で、情報を整理するためのミドルウェア"""

    def process_request(self, request):
        request.editor = create_editor_info(request)



dteditor2/editor.py


エディタの共通で使う変数や関数が詰まったファイルです。

from collections import namedtuple
import os
from django.conf import settings


# ファイル拡張子:Aceのモード名 の辞書
FILE_TYPE = {
    '.py': 'python',
    '.html': 'html',
    '.css': 'css',
    '.js': 'javascript',
    '.rst': 'rst',
    '.json': 'json',
    '.xml': 'xml',
}

# ユーザ定義のFILE_TYPEでアップデート
USER_FILE_TYPE = getattr(settings, 'FILE_TYPE', {})
FILE_TYPE.update(USER_FILE_TYPE)


# 登録されていない拡張子を開いたときのAceエディタのモード
DEFAULT_ACE_TYPE = 'plain_text'

# ユーザ定義のデフォルトモード
USER_DEFAULT_ACE_TYPE = getattr(settings, 'DEFAULT_ACE_TYPE', '')
if USER_DEFAULT_ACE_TYPE:
    DEFAULT_ACE_TYPE = USER_DEFAULT_ACE_TYPE


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

    # パラメータがなければ、manage.py があるディレクトリを返す
    current_path = request.GET.get('current_dir', 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 make_path_list(current_path='.'):
    """直下のディレクトリ、ファイルの一覧を返す

    引数:
        current_path: 基準となるパス。デフォルトは'.'

    返り値:
        current_path引数が、 '/path/to' だった例

        {
        'files': [('main.py', '/path/to/main.py')...],
        'dirs': [('..', '/path'), ('tmp', '/path/to/tmp')...],
        }
        のような辞書を返す
    """

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

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

    # ファイル一覧とディレクトリ一覧の作成処理
    files = []
    dirs = [before_dir]

    for name in files_and_dirs:
        full_path = os.path.join(current_path, name)
        if os.path.isdir(full_path):
            dirs.append((name, full_path))
        else:
            files.append((name, full_path))

    result_dict = {
        'files': files,
        'dirs': dirs,
    }

    return result_dict


Editor = namedtuple(
    'Editor',
    ['current_dir', 'opening_file', 'file_name', 'file_type'],
)


def create_editor_info(request):
    """エディタの情報を"""

    current_dir = get_current_dirpath(request)
    opening_file = request.GET.get('opening_file', '')
    if opening_file:
        file_name = os.path.basename(opening_file)
        _, file_extension = os.path.splitext(opening_file)
        file_type = FILE_TYPE.get(file_extension, DEFAULT_ACE_TYPE)
    else:
        file_name = 'no file'
        file_type = DEFAULT_ACE_TYPE

    editor = Editor(current_dir, opening_file, file_name, file_type)
    return editor



まず、ミドルウェアで呼んでいたcreate_editor_info関数と、この関数で返す名前付きタプルです。
Editor = namedtuple(
    'Editor',
    ['current_dir', 'opening_file', 'file_name', 'file_type'],
)


def create_editor_info(request):
    """エディタの情報を"""

    current_dir = get_current_dirpath(request)
    opening_file = request.GET.get('opening_file', '')
    if opening_file:
        file_name = os.path.basename(opening_file)
        _, file_extension = os.path.splitext(opening_file)
        file_type = FILE_TYPE.get(file_extension, DEFAULT_ACE_TYPE)
    else:
        file_name = 'no file'
        file_type = DEFAULT_ACE_TYPE

    editor = Editor(current_dir, opening_file, file_name, file_type)
    return editor



クラスを定義してもよかったんですが、とりあえず名前付きタプルに。
4つの属性を持っています。エディタの基準ディレクトリ、開いているファイルパス、そのファイル名、Aceで使うファイルのモード
Editor = namedtuple(
    'Editor',
    ['current_dir', 'opening_file', 'file_name', 'file_type'],
)



最終的には、上の名前付きタプルを返します。
def create_editor_info(request):
...
...
    editor = Editor(current_dir, opening_file, file_name, file_type)
    return editor


ちょっと前後しますが、opening_fileは現在開いているファイルのパスです。
これは単純にGETパラメータのopening_fileをそのまま取るだけです。
opening_file = request.GET.get('opening_file', '')


エディタの基準ディレクトリの作成は、別の関数です。
current_dir = get_current_dirpath(request)



GETパラメータのcurrent_dirを取得しつつ、デフォルト値としてDjangoプロジェクトのディレクトリを設定したり
変なGETパラメータに対応したり、ファイルだったら親ディレクトリにしたり、と色々してます。
def get_current_dirpath(request):
    """エディタの、現在のディレクトリパスを取得する"""

    # パラメータがなければ、manage.py があるディレクトリを返す
    current_path = request.GET.get('current_dir', 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


ファイルパスが取得できたら、それを元にファイル名部分とファイルタイプを作成します。
ファイル名はos.path.basenameで済みます。
    if opening_file:
        file_name = os.path.basename(opening_file)


os.path.splitextは、パス名 path を (root, ext) に分割します。C:\a\b\c\d.txtなら、C:\a\b\c\dと.txtです。
今回は拡張子部分だけ欲しいので、_, file_extension としています。
        _, file_extension = os.path.splitext(opening_file)


FILE_TYPEという辞書をこのモジュールに定義していますが、この辞書から拡張子をキーに値を取得します。.pythonなら、pythonという値です。
この値はAceエディタのモード名に対応する形で定義しています。
.aiuoとか、変な拡張子ならデフォルトのモードにします。
        file_type = FILE_TYPE.get(file_extension, DEFAULT_ACE_TYPE)


これがそのFILE_TYPEとDEFAULT_ACE_TYPEです。
settings.pyで上書きできるように作っています。
from django.conf import settings
...
...
# ファイル拡張子:Aceのモード名 の辞書
FILE_TYPE = {
    '.py': 'python',
    '.html': 'html',
    '.css': 'css',
    '.js': 'javascript',
    '.rst': 'rst',
    '.json': 'json',
    '.xml': 'xml',
}

# ユーザ定義のFILE_TYPEでアップデート
USER_FILE_TYPE = getattr(settings, 'FILE_TYPE', {})
FILE_TYPE.update(USER_FILE_TYPE)


# 登録されていない拡張子を開いたときのAceエディタのモード
DEFAULT_ACE_TYPE = 'plain_text'

# ユーザ定義のデフォルトモード
USER_DEFAULT_ACE_TYPE = getattr(settings, 'DEFAULT_ACE_TYPE', '')
if USER_DEFAULT_ACE_TYPE:
    DEFAULT_ACE_TYPE = USER_DEFAULT_ACE_TYPE



最後に、make_path_list関数です。これは画面左側のディレクトリ・ファイル一覧を作る関数です。
docstringとコメントのとおりです。
def make_path_list(current_path='.'):
    """直下のディレクトリ、ファイルの一覧を返す

    引数:
        current_path: 基準となるパス。デフォルトは'.'

    返り値:
        current_path引数が、 '/path/to' だった例

        {
        'files': [('main.py', '/path/to/main.py')...],
        'dirs': [('..', '/path'), ('tmp', '/path/to/tmp')...],
        }
        のような辞書を返す
    """

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

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

    # ファイル一覧とディレクトリ一覧の作成処理
    files = []
    dirs = [before_dir]

    for name in files_and_dirs:
        full_path = os.path.join(current_path, name)
        if os.path.isdir(full_path):
            dirs.append((name, full_path))
        else:
            files.append((name, full_path))

    result_dict = {
        'files': files,
        'dirs': dirs,
    }

    return result_dict



dteditor2/views.py


views.pyです。ミドルウェアやeditor.pyモジュールなどを作ったら、コンパクトになりました。
import cmdpr
from django.views import generic
from .command import eval_command
from .editor import make_path_list
from .forms import EditorForm


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

    def get_context_data(self, **kwargs):
        """コンテキストを追加するため、オーバーライド"""

        editor = self.request.editor
        current_dir = editor.current_dir

        # コマンド実行後に内容が変わる可能性があるので、
        # この2つはeval_command後に取得します
        path_list = make_path_list(current_dir)
        all_output = cmdpr.get_output(0)

        # contextのアップデート
        extra_context = {
            'editor': editor,
            'all_output': all_output,
            'path_list': path_list,
        }
        kwargs.update(extra_context)
        return super().get_context_data(**kwargs)

    def get_initial(self):
        """コード欄に初期値としてファイルの中身を埋め込むため上書き"""

        editor = self.request.editor
        try:
            code = open(editor.opening_file, 'rb').read().decode()
        except FileNotFoundError:
            code = ''

        initial = {
            'code': code,
        }
        return initial

    def form_valid(self, form):
        """Send Commandボタンでよびだされる"""

        # commandモジュールでformにアクセスするため、インスタンスにformを格納
        self.form = form
        cmd_string = form.cleaned_data['cmd']
        if cmd_string:
            eval_command(cmd_string, self)
        context = self.get_context_data(form=form)
        return self.render_to_response(context)



テンプレートでcurrent_dirやopening_fileを取得できるように渡しています。
path_listはディレクトリ・ファイル一覧で、all_outputはcmd.exeの現在の出力になります。
全部をeditorオブジェクトに格納するのも考えましたが、ミドルウェアのタイミングでやると色々面倒な部分があるのでやめました。(ミドルウェア→フォルダやファイルを追加するコマンド の場合などに、最新の出力やパス一覧にならない)
    def get_context_data(self, **kwargs):
        """コンテキストを追加するため、オーバーライド"""

        editor = self.request.editor
        current_dir = editor.current_dir

        # コマンド実行後に内容が変わる可能性があるので、
        # この2つはeval_command後に取得します
        path_list = make_path_list(current_dir)
        all_output = cmdpr.get_output(0)

        # contextのアップデート
        extra_context = {
            'editor': editor,
            'all_output': all_output,
            'path_list': path_list,
        }
        kwargs.update(extra_context)
        return super().get_context_data(**kwargs)



フォームに初期値を与えるget_initialもオーバーライドしてます。
現在開いているファイルをread()し、中身をformのcodeに格納するだけです。これでファイルを開く動作にしています。
    def get_initial(self):
        """コード欄に初期値としてファイルの中身を埋め込むため上書き"""

        editor = self.request.editor
        try:
            code = open(editor.opening_file, 'rb').read().decode()
        except FileNotFoundError:
            code = ''

        initial = {
            'code': code,
        }
        return initial


FileNotFoundErrorは、今現在開いているファイルを削除した場合によく起こります。
このget_initialは毎回呼ばれることになるので、毎回ファイルを読み込みなおしています...ポジティブに考えると、ファイルに変更があればそれをすぐに反映できる、ということでもあります。
また、POSTでデータが送られてきた場合は、初期値よりもPOSTで送られたフォームの内容が優先されます。

コード入力欄になにか変更をし、画面左のディレクトリ移動(これはGETなのです)なんかをすると、変更した内容は反映されません。
もっと簡潔に言うと、Send Commandボタン押下時のみ、フォームのコード入力欄が渡ってきます...この辺はもう少し何とかしたいですね。


そしてform_validです。コマンド入力欄のコマンドを、command.pyのeval_command関数に渡すだけです。
その際に、formやrequestの参照も渡したいので、selfとしてviewインスタンスを渡しています...ちょっと気持ち悪いですね。
    def form_valid(self, form):
        """Send Commandボタンでよびだされる"""

        # commandモジュールでformにアクセスするため、インスタンスにformを格納
        self.form = form
        cmd_string = form.cleaned_data['cmd']
        if cmd_string:
            eval_command(cmd_string, self)
        context = self.get_context_data(form=form)
        return self.render_to_response(context)



dteditor2/command.py


内容はdocstringのとおりです。

"""コマンドに関するモジュール

このモジュール内に関数を登録すると、それはコマンド名として使えます
関数名と元々のコマンドが衝突衝突したら、ここの関数が優先されます

※使い方
eval_commanへコマンドの文字列とviewのインスタンスを渡してください
現状ではviewのインスタンスはformやrequestへのアクセスをするのに使っています

※注意点
「python」や「python manage.py runserver」等の特定の入力があるまで無限ループ
をする処理は現在上手く動きません
この場合は、「start python」、「start python manage.py runserver」としてください

不具合が起きたら、このアプリを一旦終了して再起動してください
"""
import os
import sys
import shutil
import cmdpr


def eval_command(cmd_string, view):
    """入力されたコマンドを評価する

    コマンド名がこのモジュールの関数ならば、それを呼び出す
    なければ、通常のコマンドとして呼び出す
    """

    commands = cmd_string.split()
    command_name = commands[0]
    command_args = commands[1:]
    function = globals().get(command_name)

    # 空じゃなくて、関数として呼べそう
    if function and callable(function):
        try:
            function(view, *command_args)
        except TypeError as e:
            cmdpr.add_line(f'引数が一致しません {e}')

    # このモジュールにコマンドがとうろく登録されていない
    else:
        cmdpr.run_win_cmd(cmd_string)


##########################################
# 以下は登録したコマンドです             #
##########################################

def save(view, file_name=None):
    """プログラムの保存を行う

    save test.py で、test.pyとして保存
    save で、開いているファイルの上書き保存
    """

    editor = view.request.editor
    code = view.form.cleaned_data['code'] + '\r\n'
    binary_code = code.encode('utf-8')

    if file_name:
        file_path = os.path.join(editor.current_dir, file_name)
        if os.path.exists(file_path):
            cmdpr.add_line(f'既にファイルが存在します {file_path}')
        else:
            with open(file_path, 'wb') as file:
                file.write(binary_code)
            cmdpr.add_line(f'新しく保存しました {file_path}')

    elif not file_name and editor.opening_file:
        with open(editor.opening_file, 'wb') as file:
            file.write(binary_code)
        cmdpr.add_line(f'上書き保存しました {editor.opening_file}')
    else:
        cmdpr.add_line(f'ファイル名を指定するか、ファイルを開いてください')


def deletelog(view):
    """出力を一度削除する"""

    cmdpr.delete_cmd_log()
    cmdpr.add_line(f'出力をクリアーしました')


def current(view):
    """エディタ左側のディレクトリへ、cdする"""

    editor = view.request.editor
    cmd = f'cd {editor.current_dir}'
    cmdpr.run_win_cmd(cmd)
    cmdpr.add_line(f'移動しました {editor.current_dir}')


def rm2(view, file_name):
    """ファイル・ディレクトリの削除"""

    editor = view.request.editor
    path = os.path.join(editor.current_dir, file_name)

    if os.path.isfile(path):
        os.remove(path)
        cmdpr.add_line(f'ファイルを削除しました {path}')
    elif os.path.isdir(path):
        shutil.rmtree(path)
        cmdpr.add_line(f'ディレクトリを削除しました {path}')
    elif not os.path.exists(path):
        cmdpr.add_line(f'ファイル・ディレクトリがないです {path}')


def mv2(view, before, after):
    """ファイル・ディレクトリのリネーム・移動"""

    editor = view.request.editor
    before_path = os.path.join(editor.current_dir, before)
    after_path = os.path.join(editor.current_dir, after)

    if not os.path.exists(before_path):
        cmdpr.add_line(f'名前が見当たらないです {before_path}')
    else:
        shutil.move(before_path, after_path)
        cmdpr.add_line(f'mvしました {before}→{after}')


def run_check(file_path):
    """checkコマンドで呼ばれるヘルパー関数

    pep8、pyflakes、pylintを実行する
    {sys.executable}で、Djangoを動かしているPythonのpep8等を利用することに注意
    (これは仮想環境内でも確実にcheckコマンドを動かすためです)
    """

    cmdpr.run_win_cmd(f'{sys.executable} -m pep8 {file_path}')
    cmdpr.run_win_cmd(f'{sys.executable} -m pyflakes {file_path}')
    cmdpr.run_win_cmd(f'{sys.executable} -m pylint {file_path}')


def check(view, file_name=None):
    """ファイルのチェックを行う

    check file_name とすると、ファイルに対してチェック
    check のみだと、今開いているファイルに対してチェック
    """

    editor = view.request.editor

    # 「check file_name」 ファイル名の指定があれば、そのファイルをチェック
    if file_name:
        file_path = os.path.join(editor.current_dir, file_name)
        if not os.path.exists(file_path):
            cmdpr.add_line(f'ファイルが存在しません {file_path}')
        else:
            run_check(file_path)

    # 「check」file_nameがなければ、今開いているファイルをチェック
    elif not file_name and editor.opening_file:
        run_check(editor.opening_file)
    else:
        cmdpr.add_line(f'ファイル名を指定するか、ファイルを開いてください')


def auto(view, relative_path='.'):
    """pythonファイルにautopep8を行う

    auto path とすると、そのパスか、そのパス内内のpythonファイルに
    auto のみだと、カレント(Current Dir)のpythonファイルに

    引数:
        relative_path: カレントディレクトリからの、相対パス
    """

    editor = view.request.editor
    path = os.path.join(editor.current_dir, relative_path)

    # 指定あるけど存在しないパス
    if not os.path.exists(path):
        cmdpr.add_line('存在しないパスです')

    # パスがファイルで、pythonファイルなら実行
    elif os.path.isfile(path) and path.endswith('.py'):
        cmd = f'{sys.executable} -m autopep8 -i {path}'
        cmdpr.run_win_cmd(cmd)

    # パスがファイルで、pythonファイルじゃない
    elif os.path.isfile(path):
        cmdpr.add_line('pythonファイルかディレクトリを選択して')

    # パスがディレクトリなら、中のpythonファイルに実行
    elif os.path.isdir(path):
        for file_name in os.listdir(path):
            if file_name.endswith('.py'):
                file_path = os.path.join(path, file_name)
                cmd = f'{sys.executable} -m autopep8 -i {file_path}'
                cmdpr.run_win_cmd(cmd)




cmdprモジュール関連はここには載ってないので簡単に説明します。
# cmd.exeに、引数のcmd_stringをそのまま入力する
cmdpr.run_win_cmd(cmd_string)

# 出力に、何か文字列を足したい場合に使う。既にファイルが存在します、など
cmdpr.add_line(string)

# 出力を全て削除する。出力エリアが綺麗になります。
cmdpr.delete_cmd_log()



そして、各コマンドです。
追加するのも結構簡単にできます...追加用のpythonファイルを作ったりしてもよかったかもしれない。add_commands.pyみたいなのを、settngs.pyと同階層に。
# ファイル名でコードを保存
save filename

# コードを上書き保存
save

# 出力をクリア
deletelog

# 画面左の、基準となるディレクトリにcmd.exeも移動する。cd current_dir のような処理
current

# ファイル・ディレクトリの削除。rm -rfと大体同じ
rm2

# mvコマンドと代替おなじ
mv2

# pep8、pyflakes、pylintを開いているファイルに行う
check

# pep8、pyflakes、pylintをファイル名に行う
check filename

# autopep8を、そのディレクトリ内のpythonファイルに行う
auto directory

# autopep8を、カレントディレクトリ内のpythonファイルに行う
auto


dteditor2/static/dteditor2/base.css


高さを調節してるcssです。
ページの範囲を画面の高さに合わせるためにhtml、body、formあたりにheight:100%をし、その子要素のnav、#main-wrapper、#output-wrapperで5%、60%、35%と分けています。
更に、#outputと#cmdでまた90%と10%ですね。
html, body {
    height: 100%;
}

.scroll {
    overflow: scroll;
}

form {
    height: 100%;
}

nav {
    height: 5%;
}

#main-wrapper {
    height: 60%;
}

#output-wrapper {
    height: 35%;
}

#output {
    height: 90%;
}

#cmd {
    height: 10%;
}



dteditor2/templates/dteditor2/base.html


Bootstrap4です。
{% 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">
 
    <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">

    <!-- Custom CSS -->
    <link rel="stylesheet" href="{% static 'dteditor2/base.css' %}">
  </head>
  <body>
    {% block content %}{% endblock %}

    <!-- jQuery first, then Tether, then Bootstrap JS. -->
    <script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" 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>

    <!-- Ace Editor settings -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ace.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ext-language_tools.js"></script>
    <script src="https://cloud9ide.github.io/emmet-core/emmet.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ext-emmet.js"></script>
    <script>
        var langTools = ace.require("ace/ext/language_tools");
        var editor = ace.edit("code");
        var textarea = $('textarea[name="code"]').hide();
        editor.getSession().setValue(textarea.val());
        editor.getSession().on('change', function(){
          textarea.val(editor.getSession().getValue());
        });
        editor.$blockScrolling = Infinity;
        editor.setOptions({
            enableBasicAutocompletion: true,
            enableSnippets: true,
            enableLiveAutocompletion: true,
            enableEmmet: true,
        });
        editor.setTheme("ace/theme/monokai");
        editor.getSession().setMode("ace/mode/{{ editor.file_type }}");
        editor.setFontSize(20);
    </script>

    <script>
        window.onload = function () {
            $('#output').animate({scrollTop: $('#output')[0].scrollHeight}, 0);
            $('#id_cmd').val('');
            $('#id_cmd').focus();
        }
    </script>
  </body>
</html>



ここがAceエディタの設定。正直よくわかっていない。
editor.getSession().setMode("ace/mode/{{ editor.file_type }}");とすることで、modeを変えています。
    <script>
        var langTools = ace.require("ace/ext/language_tools");
        var editor = ace.edit("code");
        var textarea = $('textarea[name="code"]').hide();
        editor.getSession().setValue(textarea.val());
        editor.getSession().on('change', function(){
          textarea.val(editor.getSession().getValue());
        });
        editor.$blockScrolling = Infinity;
        editor.setOptions({
            enableBasicAutocompletion: true,
            enableSnippets: true,
            enableLiveAutocompletion: true,
            enableEmmet: true,
        });
        editor.setTheme("ace/theme/monokai");
        editor.getSession().setMode("ace/mode/{{ editor.file_type }}");
        editor.setFontSize(20);
    </script>



出力エリアのログはどんどんたまっていきます。
これは、出力エリアを一番下までスクロールし、コマンド入力欄のクリアとフォーカスを行っています。これのおかげで、素早いコマンド操作ができます。
    <script>
        window.onload = function () {
            $('#output').animate({scrollTop: $('#output')[0].scrollHeight}, 0);
            $('#id_cmd').val('');
            $('#id_cmd').focus();
        }
    </script>




dteditor2/templates/dteditor2/home.html


そして、メインとなるhome.html
{% extends "dteditor2/base.html" %}
{% block title %}
  {{ editor.file_name }} - {{ editor.opening_file }}
{% endblock %}

{% block content %}
<form action='' method="POST">

<nav class="navbar navbar-inverse bg-inverse py-1">
    <a class="navbar-brand" href="#">django-torina-editor2</a>
</nav>

<!-- ファイル選択とコード入力欄 -->
<div class="row" id="main-wrapper">
    
    <!-- ファイル選択エリア -->
    <div class="col-2 scroll pt-1">
        <div class="container-fluid">
            <p>Directory</p>
            {% for dir in path_list.dirs %}
            <a href="?current_dir={{ dir.1 }}&opening_file={{ editor.opening_file }}">
                {{ dir.0 }}
            </a>
            <hr>
            {% endfor %}
    
            <p>File</p>
            {% for file in path_list.files %}
            <a href="?current_dir={{ editor.current_dir }}&opening_file={{ file.1 }}">
                {{ file.0 }}
            </a>
            <hr>
            {% endfor %}
        </div>
    </div>

    <!-- コード入力エリア -->
    <div class="col-7 pt-1">
        {{ form.code }}
        <div id="code" class="h-100"></div>
    </div>

    <!-- 設定等エリア -->
    <div class="col-3 pt-1">
        <p>
            <span class="text-muted">Opening File:</span><br>
            {{ editor.opening_file }}
        </p>
        
        <p>
            <span class="text-muted">Opening File Name</span><br>
            {{ editor.file_name }}
        </p>
    
        <p>
            <span class="text-muted">Ace Editor type:</span><br>
            {{ editor.file_type }}
        </p>
    
        <p>
            <span class="text-muted">Current Dir</span><br>
            {{ editor.current_dir }}
        </p>
    </div>
</div>

<!-- 出力表示エリア -->
<div id="output-wrapper">
    <div id="cmd" class="container-fluid">
        {{ form.cmd }}
        <button type="submit" class="btn btn-info btn-sm">
            Send Command
        </button>
    </div>
    
    <div id="output" class="bg-inverse scroll">
        <div class="container-fluid">
            {% for output in all_output %}
            <span class="text-white">{{ output }}</span><br>
            {% endfor %}
        </div>
    </div>
</div>

{% csrf_token %}
</form>

{% endblock %}


タイトル部分もちゃんと変更させます。ファイル名 - パス のようになります。
Ctrl+クリックでの新しいタブで開いたりすることで、複数タブのように操作できます。その際にファイル名が見えないとよくないので、つけました。
{% block title %}
  {{ editor.file_name }} - {{ editor.opening_file }}
{% endblock %}



actionは''にしておきます。GETパラメータでカレントディレクトリや開いているファイルを管理しているため、そのGETパラメータが消えないように注意する必要があります。
空欄だと現在のURLにそのままPOSTできるので、GETパラメータもくっついていきます。
<form action='' method="POST">



ディレクトリとファイルの一覧です。
テンプレートでのタプルは、.0や.1という風にアクセスします。
ディレクトリクリック時はcurrent_dirを、ファイルクリック時はopening_fileを変更しています。
            <p>Directory</p>
            {% for dir in path_list.dirs %}
            <a href="?current_dir={{ dir.1 }}&opening_file={{ editor.opening_file }}">
                {{ dir.0 }}
            </a>
            <hr>
            {% endfor %}
    
            <p>File</p>
            {% for file in path_list.files %}
            <a href="?current_dir={{ editor.current_dir }}&opening_file={{ file.1 }}">
                {{ file.0 }}
            </a>
            <hr>
            {% endfor %}