torinaブログ

DjangoとBootstrap4で作成したブログ
Python, Django, Kivy, Bootstrap, Apache等のメモです
ソースコード

Kivyで、シンプルなエディタ②

Python Kivy Bitbucketにソースあり Kivy
約115日前 2016年10月28日2:52
Kivyで、シンプルなエディタ①
https://torina.top/main/304/

の別バージョンです。

まず開くとこんな感じ。New、Open、Saveといったボタンが増えました。


左下のはスピナーです。Pythonのバージョンなんかを切り替えれます。


実際に使用してみます。こちらはPYthon3.5


そして3.4。ちゃんと動作してますね。


新しいファイルを保存しようとすると、こんな感じになります。


Openを押すとこのように、.pyファイルが表示されますね。さきほど保存したファイルもあります。


選択するとちゃんと開けます。


Kivy1.9.2
Python3.4
です。

main.py
from io import BytesIO
import os
import shutil
import subprocess
import sys
from kivy.app import App
from kivy.properties import ObjectProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.popup import Popup
from kivy.uix.spinner import Spinner
import settings

def execute_python(file_path, python_path=sys.executable):
    """Pythonファイルを実行し、結果を返す

    Args:
        file_path: ファイルのパス
        python_path: Pythonのパス。デフォルトはこのファイルを実行しているPythonパス

    Return:
        標準出力とエラー内容
    """

    cmd = '{0} -u {1}'.format(python_path, file_path)
    ret = subprocess.getoutput(cmd)
    return ret


def create_file(byte_src, file_path):
    """ファイルを作成する

    Args:
        byte_src: ファイルの中身となるソース(バイト)
        file_path: ファイルを保存するパス
    """

    with open(file_path, "wb") as fdst:
        fsrc = BytesIO(byte_src)
        shutil.copyfileobj(fsrc, fdst)


class PopupChooseFile(BoxLayout):
    """ファイル選択用レイアウト。Editorのopen()で呼ばれる。"""

    # 現在のカレントディレクトリ。FileChooserIconViewのpathに渡す
    current_dir = os.path.dirname(os.path.abspath(__file__))

    # 実際に呼び出されるのは、Editorのselect_file, cancel
    select = ObjectProperty(None)
    cancel = ObjectProperty(None)

    def is_pyfile(self, directory, filename):
        """Pythonファイルならば、Trueを返す"""

        return filename.endswith('.py')


class PopupChooseDir(BoxLayout):
    """フォルダ選択用レイアウト。Editorのsave()で呼ばれる(新規作成処理)"""

    # 現在のカレントディレクトリ。FileChooserIconViewのpathに渡す
    current_dir = os.path.dirname(os.path.abspath(__file__))

    # 実際に呼び出されるのは、Editorのselect_dir, cancel
    select = ObjectProperty(None)
    cancel = ObjectProperty(None)

    def is_dir(self, directory, filename):
        """ディレクトリならばTrueを返す"""

        return os.path.isdir(os.path.join(directory, filename))


class VersionSpinner(Spinner):
    """Pythonのバージョンを表示、管理するウィジェット"""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        keys = list(settings.PYTHON_PATH.keys())
        self.text = keys[0]
        self.values = keys


class Editor(BoxLayout):
    """ルートウィジェットで、エディタ機能を管理する総合クラス"""

    code_view = ObjectProperty(None)  # コード入力欄
    result_view = ObjectProperty(None)  # 結果表示欄
    version_spinner = ObjectProperty(None)  # バージョン表示欄
    info = ObjectProperty(None)  # お知らせ欄(右上)
    file_path = None  # 開いたファイルのパス

    def run(self):
        """実行処理"""

        # ファイルが保存済みの場合のみ実行可能
        if self.file_path:
            python_version = self.version_spinner.text
            python_path = settings.PYTHON_PATH[python_version]
            result = execute_python(self.file_path, python_path=python_path)
            self.result_view.text = result
        else:
            self.info.text = 'Pleas Save File'

    def new(self):
        """新規ファイル"""

        self.code_view.text = ''
        self.file_path = None
        self.info.text = 'New File'

    def open(self):
        """ファイルを開く"""

        content = PopupChooseFile(select=self.select_file, cancel=self.cancel)
        self.popup = Popup(title="Select .py file", content=content)
        self.popup.open()

    def cancel(self):
        """ファイル選択画面でキャンセル"""

        self.popup.dismiss()
        self.info.text = 'cancel'

    def select_file(self, file_path):
        """ファイル選択"""

        self.code_view.text = open(file_path, 'rb').read()
        self.popup.dismiss()
        self.file_path = file_path
        self.info.text = 'Open File {0}\n{1}'.format(
            os.path.basename(file_path), file_path)

    def select_dir(self, path, file_name):
        """ファイル保存"""

        # 保存できるファイルは、.pyのファイル
        if file_name.endswith('.py'):
            file_path = os.path.join(path, file_name)
            create_file(self.code_view.text.encode(), file_path)
            self.popup.dismiss()
            self.file_path = file_path
            self.info.text = 'Create File {0}\n{1}'.format(file_name, file_path)

    def save(self):

        # 上書き保存
        if self.file_path:
            with open(self.file_path, 'wb') as file:
                file.write(self.code_view.text.encode('utf-8'))
            self.is_changed = False
            self.info.text = 'OverWrite Save {0}\n{1}'.format(
                os.path.basename(self.file_path), self.file_path)

        # 新規作成
        else:
            content = PopupChooseDir(
                select=self.select_dir, cancel=self.cancel)
            self.popup = Popup(title="Select Directory", content=content)
            self.popup.open()


class Editorina(App):

    icon = "ico.png"

    def build(self):
        return Editor()

Editorina().run()


editorina.kv
<Editor>:
    code_view: code
    result_view: result
    version_spinner: version
    info: info

    orientation: 'vertical'
    padding: 20

    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        Button:
            size_hint: .1, 1
            text: 'New'
            on_release: root.new()
        Button:
            size_hint: .1, 1
            text: 'open'
            on_release: root.open()
        Button:
            size_hint: .1, 1
            text: 'save'
            on_release: root.save()
        Label:
            id: info
            size_hint: .7, 1
            text: "NewFile"
            color: 1, 0, 0, 1

    CodeInput:
        id: code
        size_hint: 1, .6
        text: ''
        
    TextInput:
        id: result
        readonly: True
        text: 'write and run code'
        size_hint: 1, .2
    
    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        VersionSpinner:
            id: version
            size_hint: .4, 1
        Button:
            size_hint: .6, 1
            text: 'run'
            on_release: root.run()


<PopupChooseFile>:
    canvas:
        Color:
            rgba: 0, 0, .4, 1
        Rectangle:
            pos: self.pos
            size: self.size
    
    orientation: "vertical"
 
    FileChooserIconView:
        size_hint: 1, .9
        path: root.current_dir
        filters: [root.is_pyfile]
        on_submit: root.select(self.selection[0])

    Button:
        size_hint: 1, .1
        text: "Cancel"
        background_color: 0,.5,1,1
        on_release: root.cancel()

<PopupChooseDir>:
    orientation: 'vertical'
    canvas:
        Color:
            rgba: 0, 0, .4, 1
        Rectangle:
            pos: self.pos
            size: self.size

    FileChooserIconView:
        id: choose_dir
        size_hint: 1, .8
        path: root.current_dir
        filters: [root.is_dir]

    BoxLayout:
        size_hint: 1, .1
        orientation: 'horizontal'
        Button:
            size_hint: .5, 1
            text: "Cancel"
            background_color: 0,.5,1,1
            on_release: root.cancel()
        Button:
            size_hint: .5, 1
            text: "Save"
            background_color: 0,.5,1,1
            on_release: root.select(choose_dir.path, file_name.text) 

    BoxLayout:
        size_hint: 1, .1
        orientation: 'horizontal'
        Label:
            size_hint: .3, 1
            text: "File Name"
        TextInput:
            id: file_name
            size_hint: .7, 1
            multiline: False  
            font_size: 30


settings.py
from collections import OrderedDict
import os

PYTHON_PATH = OrderedDict({
    'python34': os.path.join('c:', os.sep, 'python34', 'python.exe'),
    'python35': os.path.join('c:', os.sep, 'python35', 'python.exe'),
})



settings.pyには、Pythonインタプリタのパスを指定しています。
OrderedDictを使っているのは、一番上のPythonをデフォルトで指定した状態にしたかったため、順序のある辞書を使用する必要がありました。
PYTHON_PATH = OrderedDict({


keyのpython34等の文字列は、そのままスピナーに表示されます。value部分については補足が必要でしょう。
    'python34': os.path.join('c:', os.sep, 'python34', 'python.exe'),
    'python35': os.path.join('c:', os.sep, 'python35', 'python.exe'),



os.path.join(path, *paths)(原文)
1 つあるいはそれ以上のパスの要素を賢く結合します。戻り値は path、ディレクトリの区切り文字 (os.sep) を *paths の各パートの(末尾でない場合の空文字列を除いて)頭に付けたもの、これらの結合になります。最後の部分が空文字列の場合に限り区切り文字で終わる文字列になります。付け加える要素に絶対パスがあれば、それより前の要素は全て破棄され、以降の要素を結合します。

Windows の場合は、絶対パスの要素 (たとえば r' oo') が見つかった場合はドライブレターはリセットされません。要素にドライブレターが含まれていれば、それより前の要素は全て破棄され、ドライブレターがリセットされます。各ドライブに対してカレントディレクトリがあるので、 os.path.join("c:", "foo") によって、 c: oo ではなく、ドライブ C: 上のカレントディレクトリからの相対パス(c:foo) が返されることに注意してください。



ちょっとしたスクリプトで試してみましょう。
念のため、repr出力も見ておきます。
import os
path1 = os.path.join('c', 'python35', 'python.exe')
path2 = os.path.join('c:', 'python35', 'python.exe')
path3 = os.path.join('c:', os.sep, 'python35', 'python.exe')

print(repr(path1), path1)
print(repr(path2), path2)
print(repr(path3), path3)


結果。path3のような指定をすると、正しいパスになることがわかります。
'c\python35\python.exe' c\python35\python.exe
'c:python35\python.exe' c:python35\python.exe
'c:\python35\python.exe' c:\python35\python.exe


スタックオーバーフローにも良いのがありました。
http://stackoverflow.com/questions/2422798/python-os-path-join-on-windows


次にeditorina.kvです。
まず、ルートウィジェットであるEditorについて。こいつはmain.pyに書いてますが、BoxLayoutを継承しています。
大まかなレイアウトは、垂直方向にBoxLayout(NewやOpen等のボタン部分)を1割、CodeInput(コード入力欄)を6割
BoxLayout(結果表示欄)を2割、RunやPYthonバージョンスピナー部分を1割
というレイアウトです。
BoxLayoutがネストしていて、ちょっとわかりづらいですね。
<Editor>:
    code_view: code
    result_view: result
    version_spinner: version
    info: info

    orientation: 'vertical'
    padding: 20

    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        Button:
            size_hint: .1, 1
            text: 'New'
            on_release: root.new()
        Button:
            size_hint: .1, 1
            text: 'open'
            on_release: root.open()
        Button:
            size_hint: .1, 1
            text: 'save'
            on_release: root.save()
        Label:
            id: info
            size_hint: .7, 1
            text: "NewFile"
            color: 1, 0, 0, 1

    CodeInput:
        id: code
        size_hint: 1, .6
        text: ''
        
    TextInput:
        id: result
        readonly: True
        text: 'write and run code'
        size_hint: 1, .2
    
    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        VersionSpinner:
            id: version
            size_hint: .4, 1
        Button:
            size_hint: .6, 1
            text: 'run'
            on_release: root.run()


NewやOpenボタンの部分です。horizontal(水平)で、1:1:1:7でボタン、ボタン、ボタン、お知らせ部分となるラベルです。
root.save等は、main.pyのsaveメソッド等になります。
    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        Button:
            size_hint: .1, 1
            text: 'New'
            on_release: root.new()
        Button:
            size_hint: .1, 1
            text: 'open'
            on_release: root.open()
        Button:
            size_hint: .1, 1
            text: 'save'
            on_release: root.save()
        Label:
            id: info
            size_hint: .7, 1
            text: "NewFile"
            color: 1, 0, 0, 1



コード入力欄と結果表示欄ですね。ここはシンプルです。
    CodeInput:
        id: code
        size_hint: 1, .6
        text: ''
        
    TextInput:
        id: result
        readonly: True
        text: 'write and run code'
        size_hint: 1, .2


一番下のRunボタンやバージョンを表示しているスピナー部分です。
horizontalで水平に、左から4割がスピナー、残りがRunボタンです。
    BoxLayout:
        orientation: 'horizontal'
        size_hint: 1, .1
        VersionSpinner:
            id: version
            size_hint: .4, 1
        Button:
            size_hint: .6, 1
            text: 'run'
            on_release: root.run()



次はポップアップで開かれるウィジェットです。
PopupChooseFileは、Openを押した際のファイル選択ダイアログ...正確には、Popupのコンテンツとなる部分です。
canvasでやってるのは、ただの背景色の設定です。こいつもBoxLayoutを継承させています。
上9割は FileChooserIconViewというファイル選択部分、残り1割はキャンセルボタンです。
<PopupChooseFile>:
    canvas:
        Color:
            rgba: 0, 0, .4, 1
        Rectangle:
            pos: self.pos
            size: self.size
    
    orientation: "vertical"
 
    FileChooserIconView:
        size_hint: 1, .9
        path: root.current_dir
        filters: [root.is_pyfile]
        on_submit: root.select(self.selection[0])

    Button:
        size_hint: 1, .1
        text: "Cancel"
        background_color: 0,.5,1,1
        on_release: root.cancel()



filtersで、絞り込みができます。今回は.pyファイルだけを表示するようにしています。後ほどmain.pyで紹介します。
on_submit: はファイルをダブルクリック、self.selection[0]は選択したファイルのパスです。[0]でピンと来る方もいると思いますが、複数選択なんかもさせれます。
    FileChooserIconView:
        size_hint: 1, .9
        path: root.current_dir
        filters: [root.is_pyfile]
        on_submit: root.select(self.selection[0])


PopupChooseDirは。ディレクリ選択に特化させたウィジェットです。
Saveボタンを押した際に、新規ファイルであればこれが開かれます。
PopupChooseFileと基本的には同じです。
filtersでディレクトリのみ絞り込んでいたり、Saveボタンを押すとフォルダのパスと入力したファイル名を渡していたり、といった違いだけです。
<PopupChooseDir>:
    orientation: 'vertical'
    canvas:
        Color:
            rgba: 0, 0, .4, 1
        Rectangle:
            pos: self.pos
            size: self.size

    FileChooserIconView:
        id: choose_dir
        size_hint: 1, .8
        path: root.current_dir
        filters: [root.is_dir]

    BoxLayout:
        size_hint: 1, .1
        orientation: 'horizontal'
        Button:
            size_hint: .5, 1
            text: "Cancel"
            background_color: 0,.5,1,1
            on_release: root.cancel()
        Button:
            size_hint: .5, 1
            text: "Save"
            background_color: 0,.5,1,1
            on_release: root.select(choose_dir.path, file_name.text) 

    BoxLayout:
        size_hint: 1, .1
        orientation: 'horizontal'
        Label:
            size_hint: .3, 1
            text: "File Name"
        TextInput:
            id: file_name
            size_hint: .7, 1
            multiline: False  
            font_size: 30



main.pyです。
以下はいつもの処理ですね。
class Editorina(App):

    icon = "ico.png"

    def build(self):
        return Editor()

Editorina().run()



これはバージョン情報を表示するスピナーです。settings.pyの内容を読み込む必要があるため、pythonファイル側にクラスを作りました。
keysには、['python34', 'python35']といったリストが渡されます。これをSpinnerのvaluesに渡すだけです。
textには、python34が入り、最初に表示される部分となります。
class VersionSpinner(Spinner):
    """Pythonのバージョンを表示、管理するウィジェット"""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        keys = list(settings.PYTHON_PATH.keys())
        self.text = keys[0]
        self.values = keys



PopupChooseDirです。is_dirは、ディレクトリだった場合のみTrueを返します。kvファイルのfiltersでこの関数を指定しており、結果的にディレクトリのみ表示されるようになります。
class PopupChooseDir(BoxLayout):
    """フォルダ選択用レイアウト。Editorのsave()で呼ばれる(新規作成処理)"""

    # 現在のカレントディレクトリ。FileChooserIconViewのpathに渡す
    current_dir = os.path.dirname(os.path.abspath(__file__))

    # 実際に呼び出されるのは、Editorのselect_dir, cancel
    select = ObjectProperty(None)
    cancel = ObjectProperty(None)

    def is_dir(self, directory, filename):
        """ディレクトリならばTrueを返す"""

        return os.path.isdir(os.path.join(directory, filename))


PopupChooseFileです。is_pyfileもfiltersで指定されている関数で、見ての通り.pyで終わっている場合のファイルのみTrueを返し、絞り込みます。
class PopupChooseFile(BoxLayout):
    """ファイル選択用レイアウト。Editorのopen()で呼ばれる。"""

    # 現在のカレントディレクトリ。FileChooserIconViewのpathに渡す
    current_dir = os.path.dirname(os.path.abspath(__file__))

    # 実際に呼び出されるのは、Editorのselect_file, cancel
    select = ObjectProperty(None)
    cancel = ObjectProperty(None)

    def is_pyfile(self, directory, filename):
        """Pythonファイルならば、Trueを返す"""

        return filename.endswith('.py')



ルートウィジェットのEditorクラスです。
各種クラス変数は、file_pathを除けばkvファイル側のウィジェットをいじくるための設定です、
class Editor(BoxLayout):
    """ルートウィジェットで、エディタ機能を管理する総合クラス"""

    code_view = ObjectProperty(None)  # コード入力欄
    result_view = ObjectProperty(None)  # 結果表示欄
    version_spinner = ObjectProperty(None)  # バージョン表示欄
    info = ObjectProperty(None)  # お知らせ欄(右上)
    file_path = None  # 開いたファイルのパス



Newボタンを押した際の処理は、シンプルです。
ファイルパスにNoneで、今回のロジックでは何のファイルも開いていない扱いになります。
infoは、右上の赤文字のお知らせ的な部分です。
    def new(self):
        """新規ファイル"""

        self.code_view.text = ''
        self.file_path = None
        self.info.text = 'New File'


Openボタンを押すと呼ばれる関数です。kivyのPopupを使うと、簡単にポップアップウィンドウを立ち上げれます。
もう少し色々するならば、ModalViewを使うことになるでしょう。
    def open(self):
        """ファイルを開く"""

        content = PopupChooseFile(select=self.select_file, cancel=self.cancel)
        self.popup = Popup(title="Select .py file", content=content)
        self.popup.open()


キャンセル。何も言う事はない
    def cancel(self):
        """ファイル選択画面でキャンセル"""

        self.popup.dismiss()
        self.info.text = 'cancel'


ファイル選択のポップアップで、ファイルをダブルクリックするとこいつが呼ばれます。
self.code_view.text = open(file_path, 'rb').read()で、選択したファイルの内容をエディタに移しています。
os.path.basename(file_path)は、ファイル名部分のみを取得しています。
    def select_file(self, file_path):
        """ファイル選択"""

        self.code_view.text = open(file_path, 'rb').read()
        self.popup.dismiss()
        self.file_path = file_path
        self.info.text = 'Open File {0}\n{1}'.format(
            os.path.basename(file_path), file_path)


こっちはフォルダ選択のポップアップ内で、Saveを押すと呼ばれます。
入力したファイル名が.pyで大丈夫なら、ファイルを作成する、という処理です。
    def select_dir(self, path, file_name):
        """ファイル保存"""

        # 保存できるファイルは、.pyのファイル
        if file_name.endswith('.py'):
            file_path = os.path.join(path, file_name)
            create_file(self.code_view.text.encode(), file_path)
            self.popup.dismiss()
            self.file_path = file_path
            self.info.text = 'Create File {0}\n{1}'.format(file_name, file_path)


エディタに入力した内容をファイルにして保存する必要があります。
それがcreate_file(self.code_view.text.encode(), file_path)です。
def create_file(byte_src, file_path):
    """ファイルを作成する

    Args:
        byte_src: ファイルの中身となるソース(バイト)
        file_path: ファイルを保存するパス
    """

    with open(file_path, "wb") as fdst:
        fsrc = BytesIO(byte_src)
        shutil.copyfileobj(fsrc, fdst)
                shutil.copyfileobj(fsrc, fdst)


ちょっと使ってみましょう。
from io import BytesIO
import shutil


def create_file(byte_src, file_path):
    """ファイルを作成する

    Args:
        byte_src: ファイルの中身となるソース(バイト)
        file_path: ファイルを保存するパス
    """

    with open(file_path, "wb") as fdst:
        fsrc = BytesIO(byte_src)
        shutil.copyfileobj(fsrc, fdst)

text = """\
こ
ん
に
ち
は
"""

create_file(text.encode(), 'kon.txt')


以下のようなkon.txtができていました。成功です。
こ
ん
に
ち
は



メイン画面での、Saveボタンを押すと呼ばれる関数です。
既にファイルが開かれてれば、エディタの内容で保存しなおします。
新規作成っぽかったら、フォルダ選択のポップアップを開き、ファイルの保存を促します。
    def save(self):

        # 上書き保存
        if self.file_path:
            with open(self.file_path, 'wb') as file:
                file.write(self.code_view.text.encode('utf-8'))
            self.is_changed = False
            self.info.text = 'OverWrite Save {0}\n{1}'.format(
                os.path.basename(self.file_path), self.file_path)

        # 新規作成
        else:
            content = PopupChooseDir(
                select=self.select_dir, cancel=self.cancel)
            self.popup = Popup(title="Select Directory", content=content)
            self.popup.open()



実行処理となるrun関数です。Runボタンですね。
今選択されているPythonのバージョンからPythonパスを取得し、execute_python関数にファイルパスと一緒に渡します。
戻ってきた結果を、結果表示欄に反映します。
    def run(self):
        """実行処理"""

        # ファイルが保存済みの場合のみ実行可能
        if self.file_path:
            python_version = self.version_spinner.text
            python_path = settings.PYTHON_PATH[python_version]
            result = execute_python(self.file_path, python_path=python_path)
            self.result_view.text = result
        else:
            self.info.text = 'Pleas Save File'



今回は、前回のexecと違いサブプロセスを起動し、Pythonファイルを実行しています。
def execute_python(file_path, python_path=sys.executable):
    """Pythonファイルを実行し、結果を返す

    Args:
        file_path: ファイルのパス
        python_path: Pythonのパス。デフォルトはこのファイルを実行しているPythonパス

    Return:
        標準出力とエラー内容
    """

    cmd = '{0} -u {1}'.format(python_path, file_path)
    ret = subprocess.getoutput(cmd)
    return ret


試しに、この関数を使ってみましょう。
a.py
import subprocess
import sys

def execute_python(file_path, python_path=sys.executable):
    """Pythonファイルを実行し、結果を返す

    Args:
        file_path: ファイルのパス
        python_path: Pythonのパス。デフォルトはこのファイルを実行しているPythonパス

    Return:
        標準出力とエラー内容
    """

    cmd = '{0} -u {1}'.format(python_path, file_path)
    ret = subprocess.getoutput(cmd)
    return ret


result = execute_python('b.py')
print(result)


b.py
import sys
print(sys.version)


a.pyを実行した結果。
3.5.2 (v3.5.2:4def2a2901a5, Jun 25 2016, 22:18:55) [MSC v.1900 64 bit (AMD64)]


http://docs.python.jp/3/library/subprocess.html

今回は、シンプルなgetoutputを使いました。python3.3からWindowsにも対応しています。
getstatusoutputを使えばステータスコードも帰り、非常に簡単に使えます。
Python3.5からはrun関数が実装され、こちらを使うことが推奨されています。
しかしkivyはまだ3.5に対応してないので、泣く泣くこちらを使っています。
下層の Popen インターフェースを直接利用する方法や、call、check_call、check_output等を利用する方法もあります。

sys.executableは、Pythonインタプリタの実行ファイルの絶対パスを返します。
上で3.5と表示されたのは、kivy以外だと私はPython3.5を使っているからですね。


エディタは、個人用にコツコツ作っていこうかと思います。
https://bitbucket.org/toritoritorina/kivy-pyeditor