タートルグラフィックスで学ぶ Python のキホン 第 25 章
教材コード
第25章

作品をJSONで保存・復元する

データ設計・検証・クラスを,持ち帰れる作品につなぐ.

この章でできるようになること
  • JSONにできる値と,保存しないものを区別する
  • with open と json.dump / json.load で保存・復元する
  • 読み込んだデータを検査してからインスタンスを作る

25.1 画面ではなくデータを保存する

PythonJSON注意
辞書オブジェクトキーは文字列にする
リスト・タプル配列読み戻すとリスト.タプルにしたいなら変換
文字列・有限の数値・真偽値・None文字列・数値・true/false・null型の違いも検査する
Square・Turtle・集合そのままは保存できない必要な属性を辞書やリストにする

クラスの動作や描画用のペンは保存しません.位置・大きさ・色と形式の version を保存し,読み込み時に Square を作り直します.JSONはプログラムではありません.eval や exec で読み込まないでください.

25.2 ブラウザ内のファイルと手元のファイル

注意open が扱う drawing.json はブラウザ内のPythonの一時ファイルです.ページの再読み込みで消えます.手元に残すには,作成後に下の『JSONを保存』を押します.『JSONを読み込む』で選んだファイルは,同じ drawing.json に置き換えます.演習記録のZIPとは別の作品データです.

1MB以下のJSONを読み書きできます.

p25_save.py
import json
import math

def validate(data):
    if not isinstance(data, dict) or type(data.get("version")) is not int or data["version"] != 1:
        raise ValueError("形式のバージョンが違います")
    if data.get("kind", "square-art") != "square-art":
        raise ValueError("四角形の作品ではありません")
    shapes = data.get("shapes")
    if not isinstance(shapes, list) or len(shapes) > 100:
        raise ValueError("図形は100個以下のリストにしてください")
    for shape in shapes:
        if not isinstance(shape, dict):
            raise ValueError("図形は辞書にしてください")
        for key in ("x", "y", "size"):
            value = shape.get(key)
            if type(value) not in (int, float) or not math.isfinite(value):
                raise ValueError(key + " は有限の数値にしてください")
        if not 0 < shape["size"] <= 300:
            raise ValueError("大きさは0より大きく300以下にしてください")
        if abs(shape["x"]) > 300 or abs(shape["y"]) > 200:
            raise ValueError("座標が範囲外です")
        if shape.get("color") not in ("blue", "red", "orange", "green"):
            raise ValueError("使用できない色です")
    return data

data = {"version": 1, "shapes": [
    {"x": -100, "y": 0, "size": 50, "color": "blue"},
    {"x": 0, "y": 0, "size": 80, "color": "orange"}]}
validate(data)
with open("drawing.json", "w", encoding="utf-8") as file:
    json.dump(data, file, ensure_ascii=False, indent=2, allow_nan=False)
print("drawing.json を作りました.教材のJSON保存ボタンで取り出せます.")

with のブロックを抜けるとファイルが閉じられます.'w' は上書き,読み込みは既定の 'r' です.encoding='utf-8' を指定します.手元のPythonでも同じコードが使えますが,保存先は実行時の作業フォルダになります.

25.3 構文と内容を別々に検査する

json.load が成功しても,図形の形や型が正しいとは限りません.version・図形数・必須属性・数値範囲・色を validate で調べます.全部の検査が終わってから描くため,途中まで壊れた作品を表示せずに済みます.

p25_restore.py
class Square:
    def __init__(self, x, y, size, color):
        if size <= 0:
            raise ValueError("大きさは正にしてください")
        self.x = x
        self.y = y
        self.size = size
        self.color = color

    def move(self, dx, dy):
        self.x += dx
        self.y += dy

    def area(self):
        return self.size ** 2

    def draw(self, pen):
        pen.penup()
        pen.goto(self.x, self.y)
        pen.setheading(0)
        pen.pencolor(self.color)
        pen.pendown()
        for _ in range(4):
            pen.forward(self.size)
            pen.left(90)

import json
import math

def validate(data):
    if not isinstance(data, dict) or type(data.get("version")) is not int or data["version"] != 1:
        raise ValueError("形式のバージョンが違います")
    if data.get("kind", "square-art") != "square-art":
        raise ValueError("四角形の作品ではありません")
    shapes = data.get("shapes")
    if not isinstance(shapes, list) or len(shapes) > 100:
        raise ValueError("図形は100個以下のリストにしてください")
    for shape in shapes:
        if not isinstance(shape, dict):
            raise ValueError("図形は辞書にしてください")
        for key in ("x", "y", "size"):
            value = shape.get(key)
            if type(value) not in (int, float) or not math.isfinite(value):
                raise ValueError(key + " は有限の数値にしてください")
        if not 0 < shape["size"] <= 300:
            raise ValueError("大きさは0より大きく300以下にしてください")
        if abs(shape["x"]) > 300 or abs(shape["y"]) > 200:
            raise ValueError("座標が範囲外です")
        if shape.get("color") not in ("blue", "red", "orange", "green"):
            raise ValueError("使用できない色です")
    return data

from turtle import Turtle, done
try:
    with open("drawing.json", encoding="utf-8") as file:
        data = validate(json.load(file))
except (OSError, ValueError) as error:
    print("読み込めません:", error)
else:
    shapes = [Square(s["x"], s["y"], s["size"], s["color"])
              for s in data["shapes"]]
    pen = Turtle()
    pen.hideturtle()
    pen.speed(0)
    for shape in shapes:
        shape.draw(pen)
    print(len(shapes), "個の図形を復元しました")
done()

25.4 失敗もテストにする

p25_test.py
import json
import math

def validate(data):
    if not isinstance(data, dict) or type(data.get("version")) is not int or data["version"] != 1:
        raise ValueError("形式のバージョンが違います")
    if data.get("kind", "square-art") != "square-art":
        raise ValueError("四角形の作品ではありません")
    shapes = data.get("shapes")
    if not isinstance(shapes, list) or len(shapes) > 100:
        raise ValueError("図形は100個以下のリストにしてください")
    for shape in shapes:
        if not isinstance(shape, dict):
            raise ValueError("図形は辞書にしてください")
        for key in ("x", "y", "size"):
            value = shape.get(key)
            if type(value) not in (int, float) or not math.isfinite(value):
                raise ValueError(key + " は有限の数値にしてください")
        if not 0 < shape["size"] <= 300:
            raise ValueError("大きさは0より大きく300以下にしてください")
        if abs(shape["x"]) > 300 or abs(shape["y"]) > 200:
            raise ValueError("座標が範囲外です")
        if shape.get("color") not in ("blue", "red", "orange", "green"):
            raise ValueError("使用できない色です")
    return data

valid = {"version": 1, "shapes": [
    {"x": 0, "y": 0, "size": 10, "color": "blue"}]}
assert validate(json.loads(json.dumps(valid))) == valid
for bad in [{"version": 2, "shapes": []},
            {"version": 1, "shapes": [{}]},
            {"version": 1, "shapes": [{"x": 0, "y": 0, "size": -1, "color": "blue"}]}]:
    try:
        validate(bad)
    except ValueError:
        pass
    else:
        raise AssertionError("不正なデータを受け付けました")
print("往復と拒否のテスト成功")

実習:保存と復元を段階に分けて確かめる

最初は文字列の変換,次にファイル,最後に作品の復元を扱います.ブラウザのPython内で作るファイルと,手元へ取り出したファイルは別なので,それぞれの確認手順を区別してください.

実習25-1 プログラムが終わった後に,何を残したいか

コードを保存することと,実行中に作った作品のデータを保存することは違います.

同じプログラムでも,入力や操作によって図形の位置や色が変わります.次の日に編集を続けたいなら,その時点の作品の状態を残す必要があります.

画面の画像を保存すれば見た目は残りますが,個々の図形を動かすための座標や大きさを取り出すのは難しくなります.

今回保存するのは,図形の位置・大きさ・色と,図形の並び順です.

ペンの内部状態,実行中の関数,キー入力の待ち受けなどは保存しません.それらは作品のデータを使って,読み込み後にプログラムが用意し直せる道具です.

何を再現したいかを決めると,必要な保存項目と不要な項目を区別できます.

保存する形式は,Pythonを使わないプログラムからも読めると便利です.

JSONは文字列,数値,配列などの基本的な値を組み合わせる形式で,今回の図形データを表せます.ただし,Pythonのオブジェクトをそのまま何でも保存できる形式ではありません.

まず,作品の意味を表す基本的な値へ変換する作業が必要です.

以下では,作品を辞書とリストへまとめています.

まだファイルには書いていません.保存前のデータをコンソールで確認する段階です.

ここで,図形が何個あり,各図形の必須情報がそろっているかを確認します.文字列化やファイル操作を始める前に,残したいデータ自体を明確にしましょう.

w25_1_demo.py
drawing = {"version":1,"shapes":[{"x":10,"y":20,"size":40,"color":"blue"}]}
print(drawing)
print("図形数",len(drawing["shapes"]))
演習2個目の図形を加える

作品へ赤い図形を追加し,図形の順序が青,赤になっていることを確認してください.描画用のTurtleは辞書へ入れず,位置・大きさ・色だけを保存対象にします.最後に,import jsonを加えてjson.loads(json.dumps(drawing))が元の辞書と一致することも確かめます.

実習25-2 Pythonの値と,JSONの文字列を区別する

辞書を画面へprintした見た目はJSONに似ていますが,同じものではありません.

Pythonでは文字列に一重引用符を使え,真偽値はTrueやFalse,値がないことはNoneで表します.JSONの文字列は二重引用符で囲み,true,false,nullという表記を使います.

辞書をstrで文字列にしただけでは,正しいJSONになるとは限りません.

json.dumpsは,Pythonの値をJSON形式の文字列へ変換します.

末尾のsは文字列を扱う操作と覚えると,ファイルを扱うdumpとの区別に役立ちます.変換後の値は辞書ではなくstrです.

キーで値を取り出す前には,json.loadsでPythonの値へ戻す必要があります.変換前後のtypeを表示して確認しましょう.

JSONはデータを表す形式で,Pythonの式や命令を書く場所ではありません.

コメントや末尾の余分なカンマも,標準のJSONでは使えません.手で文字列を組み立てると,引用符や改行の扱いを間違えやすくなります.

辞書やリストを作り,jsonライブラリへ変換を任せると,細かな記号の処理を自分で書かずに済みます.

以下では,数値・文字列・真偽値・Noneを変換します.

出力にどの表記が現れるかを予測してください.その後loadsで戻すと,PythonのTrueやNoneへ復元されます.

ファイルを使わない往復を先に確かめておくと,後で失敗した際に,形式変換とファイル操作のどちらが原因か切り分けやすくなります.

w25_2_demo.py
import json
data = {"name":"青い四角","visible":True,"note":None,"size":40}
text = json.dumps(data,ensure_ascii=False)
print(type(data).__name__,type(text).__name__)
print(text)
restored = json.loads(text)
assert restored == data
演習strで済ませない

使う書き方:isinstance(値, 型) は,値が指定した型かを調べ,True または False を返します.例えば isinstance("こんにちは", str) は True です.assert と組み合わせると,変換後の型も確認できます.

辞書をstrで変換する代わりにjson.dumpsを使い,json.loadsで元へ戻ることを確認してください.visibleにFalseを含めて,JSONとPythonの表記が違うことを観察します.

実習25-3 配列へ変換すると,タプルはリストになる

JSONの配列は,値を順序付きで並べる形式です.

Pythonのリストもタプルも,dumpsではJSONの配列になります.しかし,loadsで読み戻すとリストになります.

そのため,タプルを含むデータは,往復しただけではPythonの型まで完全に同じにならない場合があります.保存する形式と,実行中に使いたい型を分けて考えます.

例えばpositionをタプルで持つ図形を保存した場合,復元時にtupleへ変換する方法があります.

別の方法として,保存用データでは最初からxとyを別の数値として持ち,図形を作るときに必要な形へまとめることもできます.この章の完成例は後者を使い,保存形式が特定のPythonの型へ依存しすぎないようにしています.

辞書のキーにも注意が必要です.

JSONのオブジェクトのキーは文字列です.Pythonの整数キーを変換すると,読み戻した後に文字列キーとなり,元と同じように引けない場合があります.

図形IDをキーに使う場合は,最初から文字列にそろえると扱いやすくなります.変換の前後で何が維持され,何が変わるかを小さい例で確かめましょう.

以下ではタプルの位置を往復させ,読み戻した型を表示します.

値の並びが同じでも,型が違うと==の結果が違う場合があります.テストで単純な等値比較が失敗したら,データが失われたのか,表現が変わっただけなのかを調べてください.

復元する型の約束を決めれば,どの比較が適切かを判断できます.

w25_3_demo.py
import json
original = {"position":(10,20)}
restored = json.loads(json.dumps(original))
print(type(restored["position"]).__name__)
restored["position"] = tuple(restored["position"])
assert restored == original
演習位置の型を明示して復元する

使う書き方:all(条件 for 要素 in リスト) は,すべての要素で条件が成り立つかを調べます.例えば all(n > 0 for n in [1, 2]) は True です.isinstance(値, tuple) でタプルかを確認できます.同じ検査は,通常の for 文の中で一つずつ assert しても書けます.

2個の座標タプルをJSONへ変換し,読み戻した各座標をタプルへ戻してください.元の座標一覧と一致することを確認します.外側は順序を持つリストのままにします.

実習25-4 日本語・改行・引用符を安全に表す

作品名や感想に日本語を使う場合,ensure_ascii=Falseを指定すると,JSONの文字列を日本語のまま読みやすく出力できます.

指定しない場合のUnicodeエスケープも正しいJSONであり,読み戻せば日本語になります.どちらでもデータの意味は保てますが,人がファイルを点検する教材では,日本語が見える形式のほうが理解しやすいでしょう.

文字列の中に引用符や改行があると,JSONでは必要に応じてエスケープされます.

これは文字を失ったのではなく,文字列の境界と内容を区別するための表現です.自分で引用符を置換してJSONを作ろうとすると,二重に変換したり,バックスラッシュを壊したりする原因になります.

dumpsとloadsを対で使い,元の文字列へ戻ることを確認します.

ファイルへ書くときの文字コードは,JSONへの変換とは別の話です.

ensure_ascii=Falseは日本語をどの文字で表すかを決め,encoding='utf-8'は文字列をファイルのバイト列としてどう保存するかを決めます.似た設定に見えても役割が違います.

保存側と読み込み側で文字コードをそろえることが大切です.

以下では引用符と改行を含む名前を使います.

出力文字列にどの記号が現れるかを観察した後,復元した値が完全に一致することを確かめます.画面上で改行されるか,逆斜線とnが見えるかだけで判断しないでください.

JSONの表現と,復元後の文字列の内容を区別して読みます.

w25_4_demo.py
import json
name = "青い\"四角\"\n2行目"
text = json.dumps({"name":name},ensure_ascii=False,indent=2)
print(text)
assert json.loads(text)["name"] == name
演習日本語の作品名を往復する

日本語の作品名,引用符を含む説明,改行を含む文字列を辞書にしてJSONへ変換してください.読み戻した辞書が元と一致することを確認します.文字列の記号を手作業で置換しないでください.

実習25-5 読みやすい形式と,小さい形式を使い分ける

JSONの空白や改行は,文字列の外側であれば,データの意味を変えずに整えられます.

indent=2を指定すると,入れ子に応じて字下げされ,人が構造を読みやすくなります.図形のキーが足りない場合や,配列の位置が違う場合を点検する教材では,この形式が便利です.

一方,大量の記録を送る場合には,余分な空白を省いた形式が使われます.

separatorsを指定すると,区切り記号の周囲の空白を調整できます.

小さい形式と読みやすい形式は,loadsで戻した結果が同じであることを確認できます.見た目が違うファイルを比較するとき,文字列そのものが一致しないからデータも違うと判断しないでください.

配列の順番や値は重要ですが,字下げの幅は通常データの意味ではありません.

文字数とファイルサイズも区別します.

日本語をUTF-8で保存すると,1文字が複数バイトになることがあります.len(text)は文字列の長さ,len(text.encode('utf-8'))は保存するバイト数を調べる方法です.

ブラウザの1MB制限はバイト数で考えます.英数字だけの例では差が見えにくいので,日本語を含む作品名でも確かめます.

以下では2種類の表現を作り,復元した内容が同じか確認します.

次の課題では,図形の個数を変えたときのサイズを調べます.データ量を推測だけで決めず,実際の形式で文字列化して測る練習です.

ただし,この作品JSONのサイズと,暗号化された演習記録ZIPのサイズは別のものです.何を測っているかをラベルに書いてください.

w25_5_demo.py
import json
data = {"name":"作品","shapes":[{"x":10,"y":20,"size":30}]}
pretty = json.dumps(data,ensure_ascii=False,indent=2)
compact = json.dumps(data,ensure_ascii=False,separators=(",",":"))
assert json.loads(pretty) == json.loads(compact)
print("整形",len(pretty.encode("utf-8")),"bytes")
print("圧縮表記",len(compact.encode("utf-8")),"bytes")
演習図形数とサイズの関係を測る

1個,10個,100個の図形を持つ作品を作り,JSONのUTF-8バイト数を表示してください.空白を省いた形式を使い,図形数が増えるとサイズが増えることを確認します.

実習25-6 with openで書き,閉じてから読む

json.dumpは,Pythonの値をファイルへJSONとして書きます.

openの'w'は書き込み用で,既存の同名ファイルがあれば内容を上書きします.withを使うと,そのブロックを抜けたときにファイルが閉じられます.

途中で例外が発生した場合にも閉じる処理が行われるため,手作業でcloseを書き忘れる心配を減らせます.

読み込みにはjson.loadを使います.

dumpsとloadsは文字列,dumpとloadはファイルを扱う,という対応です.名前が似ているので,引数に何を渡しているかを確認してください.

ファイル名の文字列をjson.loadsへ渡しても,ファイルを開いてくれるわけではありません.loadsはその文字列自体をJSONとして解釈しようとします.

書き込み側と読み込み側のencodingはUTF-8へそろえます.

日本語の作品名を含む例で往復を確認すると,設定の漏れを見つけやすくなります.書き込んだ直後に同じファイルを読み戻し,元のデータと比較してください.

保存できたという表示だけでなく,中身を正しく取り出せることまで確かめます.

以下はブラウザ内のPythonが使うファイルへの保存です.

手元のパソコンのフォルダへ自動的に書かれるわけではありません.この章のJSON保存ボタンで取り出す場合はdrawing.jsonという名前を使います.

名前を変えた練習ファイルはPython内で読み書きできますが,保存ボタンの対象ではないことを区別してください.

w25_6_demo.py
import json
data = {"version":1,"shapes":[]}
with open("practice.json","w",encoding="utf-8") as file:
    json.dump(data,file,ensure_ascii=False,indent=2)
with open("practice.json",encoding="utf-8") as file:
    restored = json.load(file)
assert restored == data
print(restored)
演習日本語を含むファイルを往復する

作品名『朝の風景』と図形数3をpractice.jsonへ書き,閉じた後に読み直してください.元の辞書と一致することをassertで確認します.この練習ファイルは作品復元のdrawing.jsonとは分けて使います.

実習25-7 ブラウザ内のファイルと,持ち帰るファイル

この演習環境のPythonは,ブラウザの中で動いています.

openが扱うのは,そのPythonが使う仮想的なファイル領域です.コードを続けて実行する間はファイルを読み直せますが,ページを再読み込みすると,作ったファイルが残るとは限りません.

作品を持ち帰るには,JSON保存ボタンでダウンロードする手順が必要です.

逆に,パソコン上のファイルを読みたい場合は,JSONを読み込む操作で自分が選びます.

選んだ内容がブラウザ内のdrawing.jsonへ入ります.その後,復元するPythonコードを実行します.

選んだだけで図形が描かれるのではありません.ファイルを用意する操作と,内容を解釈して描く処理を分けているためです.

コードの自動保存,作品JSON,演習記録ZIPも別のものです.

コードの保存はプログラムを残し,作品JSONは編集した図形の状態を残します.演習記録ZIPは,先生が指定する形式で学習過程を提出するためのものです.

用途が違うので,どれか1つを保存したから全部残っていると思わないようにしてください.

以下の例はPython内で作ったファイルを,次の読み込みで確認します.

次にページを再読み込みした場合,同じファイルがある前提を置かず,必要なら手元から読み込む手順を行います.再現確認では,保存直後だけでなく,新しい状態から復元できるかが重要です.

この章の最後に,ページを開き直して作品を戻す確認を行います.

w25_7_demo.py
import json
with open("temporary.json","w",encoding="utf-8") as file:
    json.dump({"message":"ブラウザ内のファイル"},file,ensure_ascii=False)
with open("temporary.json",encoding="utf-8") as file:
    print(json.load(file)["message"])
演習同じ実行環境内で読み直す

temporary.jsonへ数値10を書き,読み直して10を確認します.その後20で上書きして,再度読み直してください.前の値へ追記されるのではなく,内容が置き換わることを確かめます.

実習25-8 ファイルがない場合を,構文の問題と分ける

読み込みに失敗する理由は1つではありません.

ファイルが存在しない場合は,まだ保存していない,名前が違う,ページを再読み込みした,といった可能性があります.ファイルはあるが中身がJSONではない場合とは,調べる場所が違います.

例外の種類を見て,ファイルの用意と形式の解釈を分けて考えます.

openに失敗する場合はOSErrorの仲間の例外が発生します.

存在しない場合のFileNotFoundErrorもその一種です.そのとき利用者へは,先に保存の例題を実行するか,手元のJSONを選んで読み込むことを案内できます.

ただエラーを隠して空の作品を表示すると,元の作品が失われたように見えるかもしれません.失敗したことを明確に知らせます.

通常の作品表示では,読み込みが失敗しても,いま表示している作品を壊さない設計が望ましいでしょう.

まず別の変数へ読み込みと検証を行い,成功してから現在の作品を置き換えます.これは後の節で詳しく扱います.

この段階では,失敗時に何が起きたかを区別して伝えることを練習します.

以下の例は存在しない名前を使い,失敗を意図的に観察します.

例外を受け止めているので,プログラム全体は最後まで進みます.次の課題では,指定したファイルがない場合にNoneを返す関数を作ります.

空の辞書とNoneを区別し,失敗を有効な作品データに見せかけないようにします.

w25_8_demo.py
try:
    with open("not_created_in_this_lesson.json",encoding="utf-8") as file:
        text = file.read()
except FileNotFoundError:
    print("ファイルがありません.保存または読み込みの操作を確認してください.")
演習読み込み失敗を区別して返す

read_text(name)を作り,存在すれば文字列,存在しなければNoneを返してください.テスト用ファイルを作る場合と,存在しない名前の場合を確かめます.空ファイルは空文字列であり,Noneではありません.

実習25-9 JSONの構文エラーを読む

ファイルが読めても,中身が正しいJSONとは限りません.

引用符の閉じ忘れ,余分なカンマ,PythonのTrueをそのまま書くなどの間違いがあると,json.loadsやjson.loadはJSONDecodeErrorを出します.これはPythonプログラム自体のSyntaxErrorとは別です.

プログラムは動いていますが,入力されたデータの形式が解釈できなかった状態です.

JSONDecodeErrorには,問題の位置を調べるための行番号や列番号が含まれます.

長い作品データを目で最初から探すより,示された位置の周辺を確認します.ただし,問題の記号がその位置より前にある場合もあります.

例えば引用符の閉じ忘れでは,文字列の始まりまで戻って確認する必要があります.エラー位置を手がかりとして使います.

また,JSONDecodeErrorはValueErrorの一種です.そのため except ValueError と書けば,壊れたJSONと,自分でraiseしたValueErrorをまとめて受け止められます.

手で修正する練習は形式を理解するために役立ちますが,通常の保存処理ではdumpsやdumpを使ってJSONを作ります.

文字列を連結して記号を組み立てる方法は,名前に引用符が入ったときなどに壊れやすいからです.読み込み側は外から不正なデータが来る可能性に備え,保存側は正しい形式を生成する,という両方の対策を取ります.

以下では末尾に余分なカンマがあるデータを読み,エラーの種類と位置を表示します.

正しいJSONへ直した後は,取り出した値まで確認してください.構文が通っただけで作品として正しいとは限らないので,次の節で内容の検証へ進みます.

形式の解釈と,作品の仕様への適合は2段階です.

w25_9_demo.py
import json
text = '{"size": 30,}'
try:
    json.loads(text)
except json.JSONDecodeError as error:
    print("構文エラー", error.lineno, error.colno, error.msg)
演習余分なカンマを直す

sizeが30の辞書を表すJSONを正しく読み込めるようにしてください.その後,同じデータを辞書からjson.dumpsで作る方法と比較し,loadsした結果が一致することを確認します.

実習25-10 読めるデータと,使えるデータは違う

数字の30だけも,文字列のhelloも,JSONとしては有効です.

しかし今回の作品はversionとshapesを持つ辞書でなければなりません.loadsが成功したことは,形式として読めたことを示すだけです.

作品に必要な構造や値がそろっているかは,自分のプログラムで検査します.この区別を忘れると,後の描画処理で分かりにくいエラーになります.

検査は外側から順に行います.

最上位が辞書か,shapesがリストか,各要素が辞書か,必要なキーがあるか,値の型と範囲が正しいか,という順です.辞書であることを確かめる前にgetを呼ぶと,配列が来た場合に別のエラーになります.

構造をたどる前に,その段階の前提を確認してください.

不正な値を見つけたら,描画を始める前に止めます.

1個目は描けて2個目で失敗するより,先に全体を検査したほうが,部分的に壊れた画面を避けられます.エラーメッセージには,どの項目が問題かを含めます.

図形が多い場合は,要素番号やIDも示すと修正しやすくなります.

以下では最上位とshapesの型だけを検査します.

まだ色や座標までは確認していません.検査関数が何を保証するかを,実装の範囲に合わせて説明してください.

次の課題で,配列が最上位の場合とshapesが文字列の場合を拒否し,空の図形リストは受け付けることを確認します.

w25_10_demo.py
def validate_structure(data):
    if not isinstance(data,dict):
        raise ValueError("作品は辞書")
    if not isinstance(data.get("shapes"),list):
        raise ValueError("shapesはリスト")
    return data
print(validate_structure({"shapes":[]}))
演習正しいJSONでも拒否する場合

validate_structureへ,[],{shapes:文字列},{shapes:空リスト}に相当するPythonの値を渡してください.最初の2つはValueError,最後は成功することをテストします.

実習25-11 形式のバージョンを確かめる

作品の保存形式は,機能が増えると変わることがあります.

最初は四角形だけでも,後から円や線を追加すればkindという項目が必要になるかもしれません.古いプログラムが新しいデータを勝手に解釈すると,意図しない図形を描く可能性があります.

versionを保存しておくと,どの約束で書かれたデータかを判断できます.

今回の完成例はversionが整数1の場合だけ受け付けます.

知らない番号なら,対応していない形式だと知らせます.versionがないデータを無条件に1とみなすと,別の用途のJSONを誤って作品として扱うかもしれません.

形式を区別するための必須情報として検査します.数値の比較だけではTrueも1と等しいため,型も確認すると約束が明確になります.

古い形式を新しい形式へ移す処理を追加する場合は,元の意味を保つ変換を用意します.

変換できない情報を勝手に捨てないよう,何を補い,何を保持するかを決めます.この授業では,未知の形式を拒否する段階までを必須とし,複数バージョンの変換は発展として考えます.

必要になる前に複雑な変換機構を作る必要はありません.

以下では整数1だけを受け付けます.

文字列の1,真偽値のTrue,整数2を試すと,見た目が似ていても違う値として扱うことが分かります.第21章の型と範囲の検査を,保存データの約束へ適用する例です.

保存する側でも同じ型を使い,出力と入力の仕様をそろえてください.

w25_11_demo.py
def check_version(data):
    version = data.get("version")
    if type(version) is not int or version != 1:
        raise ValueError("未対応の形式")
    return version
print(check_version({"version":1}))
演習番号の型も検査する

versionが1なら成功し,2,文字列1,True,項目なしなら失敗するテストを残してください.True == 1が真でも,この形式では受け付けない仕様です.

実習25-12 扱える大きさを,データの制限として決める

形式が正しくても,図形が何百万個も入っていれば,授業用のブラウザでは処理に時間がかかります.

図形数,大きさ,座標,ファイルのバイト数などに,作品の目的に合った上限を決めます.これは単にエラーを防ぐためだけでなく,どの規模の作品をこの教材で扱うかという仕様です.

上限を超えたときは,理由を知らせて処理を止めます.

完成例では図形数100個以下,大きさ300以下などの約束を置いています.

数値が正でも,無限大やNaNのような値は,描画用の座標として適切ではありません.math.isfiniteで有限の値か確認します.

Pythonのjsonは一部の非標準的な数値を受け付ける場合があるため,作品側でも数値の条件を確認しておくことが重要です.

保存する際にはallow_nan=Falseを使うと,NaNや無限大をJSONへ出すことを拒否できます.

ただし,これだけで大きすぎる座標や図形数が検査されるわけではありません.JSONの形式としての制限と,作品としての制限を分けて設けます.

出力側でも入力側でも,同じ意味のデータを扱えることを確認します.

以下では有限かどうかを小さな関数で調べます.

整数と小数を許しますが,真偽値と文字列は拒否します.次の課題では,上限を含む範囲の判定も加えます.

型,有限性,範囲という3段階を意識すると,何が原因で拒否されたかを説明しやすくなります.

w25_12_demo.py
import math
def valid_number(value):
    return type(value) in (int,float) and math.isfinite(value)
for value in [10,2.5,True,"10",float("inf"),float("nan")]:
    print(repr(value),valid_number(value))
演習読み込んだ大きさを検査する

有限の数値で0より大きく300以下だけを受け付けるvalid_sizeを作ります.まずjson.loads('{"size": Infinity}')とjson.loads('{"size": NaN}')がエラーにならず読めてしまうことを確かめ,読んだsizeをvalid_sizeが拒否することをassertで示してください.0,301,Trueも拒否し,0.5と300は受け付けます.

実習25-13 辞書からクラスの個体を作り直す

JSONを読み戻して得られるのは,辞書やリストなどの基本的な値です.

Squareのmoveやdrawというメソッドが,自動的に復元されるわけではありません.検証済みの辞書からx,y,size,colorを取り出し,Square(...)を呼んで新しい個体を作ります.

保存していた状態と,プログラム側にある操作の定義を,読み込み時に結び直す作業です.

クラス名をJSONへ書いて,その名前から何でも実行する方式は,この教材には必要ありません.

扱える図形の種類を決め,必要な場合にはkindの値に応じて,自分で用意した生成処理を選べば十分です.外から来た文字列をevalやexecへ渡してはいけません.

データを読むこととプログラムを実行することを分けます.

復元時の検証をすべてクラスの初期化へ任せるか,その前にデータ全体を検査するかも考えます.

この章では,ファイル全体の構造と値を先に検査し,成功してから個体を作ります.図形のクラスも自分の大きさの条件を確認しておくと,JSON以外の呼び出しから使う場合にも約束を保ちやすくなります.

検査の対象と責任を説明できるようにします.

以下では,保存に必要な数値だけを取り出し,文字列の往復後にPointを作り直します.

元と復元後は別の個体ですが,座標は同じです.この区別をisと値の比較で確認してください.

保存と復元で求めるのは,メモリ上で同一の個体を取り戻すことではなく,作品として同じ状態を再現することです.

w25_13_demo.py
import json
class Point:
    def __init__(self,x,y):
        self.x,self.y = x,y
a = Point(10,20)
text = json.dumps({"x":a.x,"y":a.y})
data = json.loads(text)
b = Point(data["x"],data["y"])
assert a is not b
assert (a.x,a.y) == (b.x,b.y)
print(b.x,b.y)
演習複数の個体を復元する

Pointを2個保存用の辞書一覧へ変換し,JSONから読み戻した後にPointのリストを作ってください.座標と順序が一致し,復元した個体を変更しても元の個体は変わらないことを確認します.

実習25-14 成功してから,現在の作品を置き換える

新しいファイルを読み込む際,最初に現在の作品をclearしてから解析すると,読み込みに失敗したときに元の作品も消えてしまいます.

安全な手順は,新しいデータを別の変数へ読み込み,検証と復元を終えてから,現在の作品の参照を置き換えることです.途中の失敗で現在の状態を壊さないようにします.

この考え方は,ファイルだけでなく複数の更新をまとめる処理にも使えます.

まず変更後の候補を作り,必要な条件をすべて確かめ,最後に採用します.作業途中の状態を他の処理へ見せないことで,結果を説明しやすくなります.

小さな作品でも,失敗した操作の後に何が残るかを決めることが大切です.

例外を受け止める側では,候補を採用しなかったことを知らせます.

失敗したのに「読み込み完了」と表示してはいけません.逆に,すべて成功したら,作品を置き換えてから再描画します.

データの置き換えと画面の更新も,順序をそろえる必要があります.データだけ新しく画面は古い,といった状態を残さないようにします.

以下は数値のリストを作品の代わりに使い,失敗時に元の値が維持されることを確かめます.

次の課題では成功した場合もテストします.失敗時の保護だけに注目して,正常な新しい作品まで採用しない関数になっていないか確認してください.

受け付ける場合と拒否する場合の両方を試す,というデバッグ回の考え方を使います.

w25_14_demo.py
import json
def parse_values(text):
    candidate = json.loads(text)
    if not isinstance(candidate,list) or not all(type(x) is int for x in candidate):
        raise ValueError("整数のリストが必要")
    return candidate
current = [10,20]
try:
    candidate = parse_values("[30, null]")
except ValueError:
    print("元の作品を保ちます")
else:
    current = candidate
assert current == [10,20]
演習成功と失敗で置き換えを区別する

load_or_keep(current,text)を作り,整数リストとして読み込めたら新しい一覧,失敗したら元の一覧を返してください.[30,40]は採用し,[30,null]と壊れたJSONは拒否します.

実習25-15 保存直後と,開き直した後を別々に確認する

保存の直後に同じ環境で読み戻すテストは,形式とファイル操作を確かめるのに役立ちます.

しかし,それだけでは,元の変数が残っているから表示できたのか,保存データだけで復元できたのかを区別できない場合があります.最後には,元の状態がない環境から読み込み,作品を再現する確認が必要です.

ブラウザでの手順は,作品を作る,drawing.jsonへ書く,JSON保存で手元へ取り出す,ページを再読み込みする,手元のJSONを読み込む,復元コードを実行する,という流れです.

この途中で,コードを保存するボタンとJSONを保存するボタンを取り違えないようにします.どのファイルを選んだか,図形数や色が合うかを具体的に確認してください.

見た目の確認に加え,保存前後で図形のID,順序,座標,大きさ,色が一致するかを調べます.

タプルとリストの違いのように,表現が変わる部分は比較する形をそろえます.一方,図形の順序が変わることは,重なり順を変えるため意味のある違いです.

無条件に並べ替えてから比較して,順序の不具合を隠さないようにします.

以下では,変換用の関数で作品を数値と文字列の組へそろえて比較します.

このような比較用の表現を用意すると,クラスを使う実装と辞書を使う実装を比べる場合にも役立ちます.課題では,値が同じでも順序だけが違う作品を区別してください.

保存して復元する目的は,単にファイルが存在することではなく,同じ作品として続きが作れることです.

w25_15_demo.py
import json
def signature(shapes):
    return [(s["x"],s["y"],s["size"],s["color"]) for s in shapes]
shapes = [{"x":0,"y":0,"size":20,"color":"blue"},{"x":10,"y":10,"size":30,"color":"red"}]
restored = json.loads(json.dumps(shapes))
assert signature(restored) == signature(shapes)
print(signature(restored))
演習順番の変化も見つける

例題のsignatureを使い,JSONの往復後は一致し,復元後の一覧を逆順にしたら一致しないことを確かめてください.2個の図形は異なる属性にします.順序を無視する集合へ変換して比較しないでください.

25.5 総合制作:作品を交換する

演習保存できる図形作品

Square に to_data() を追加し,3個以上の Square を移動した後の状態を作品データにして drawing.json へ保存してください.読み戻したデータを validate してから Square のインスタンスを作り直し,保存前と同じ作品になったことを assert で確かめます.

  1. to_data() は {"x": …, "y": …, "size": …, "color": …} の辞書を返す(validate が調べるキーの名前と同じにする).作品データは {"version": 1, "shapes": [図形の辞書, …]} の形にする.
  2. 図形は3個以上.例えば Square(-120, 0, 30, "blue")・Square(-20, 0, 50, "red")・Square(80, 0, 60, "green") を作り,それぞれ move(0, 20) してから保存する.色は validate が許す blue・red・orange・green から選び,座標は x が -300〜300,y が -200〜200,size は 0 より大きく 300 以下にする.
  3. 読み戻して作り直した図形の (x, y, size, color) が,保存前と同じ順で一致することを assert で確かめる.作り直した図形が保存前とは別のインスタンスであること(is not)も確かめる.
  4. 大きさが負の図形(例:{"x": 0, "y": 0, "size": -5, "color": "blue"})を含むデータは validate で ValueError になり,図形が1つも作られないことを確かめる.
  5. これもやりましょう:復元した図形を,25.3 節の復元の例題と同じように Turtle で描き,「3 個の図形を復元しました」の形で個数を表示する.さらに,色が "purple"(validate が許さない色)の図形を含むデータも ValueError で拒否されることを確かめる.
評価の目安(10 段階)

必須(すべて満たして正しく動けば 8)

  1. Square に to_data() メソッドを追加し,3個以上の Square を移動した後の状態を to_data で辞書にしている
  2. version 1 を含む作品データを with open と json.dump で drawing.json に保存し,json.load で読み戻している
  3. 読み戻したデータを validate してから Square を作り直し,各図形の (x, y, size, color) が保存前と同じ順で一致することを assert で示している
  4. 大きさが負の図形・色が purple の図形を含むデータは validate で ValueError になり,図形を作らないことを確かめている
  5. 復元した図形を Turtle で描き,復元した個数を表示している

発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)

  • JSONを保存ボタンで持ち帰り,ページを開き直して読み込んだ後も同じ作品になることを確かめている
  • 第24章の Scene に to_data と復元の操作を持たせ,作品全体を1回で保存・復元している
  • 色や大きさなど自分の作品に合わせた検査を validate に加え,その理由を説明している

必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6

  1. JSONを保存し,ページを再読み込みする
  2. 保存したJSONを読み込み,復元の例題で同じ作品になるか確かめる
  3. 空の作品・壊れたJSON・負の大きさでも動作を調べる
  4. 作品JSONとコードを保存し,演習記録ZIPは先生の指示に沿ってLMSに提出する

復習:虫食いで確かめる

コードの ____ を埋めてください.実行前に結果を予測し,実行後に問題文の条件と照合しましょう.assert は条件が成り立つかを調べ,成り立たないとエラーになります.

虫食い虫食い・復習 1 JSON文字列に変換する

辞書をJSON文字列へ変換します.日本語がそのまま含まれるように空欄を補ってください.

fill_review_json_project_1.py
import json
data = {"title": "作品"}
text = json.____(data, ensure_ascii=____)
assert "作品" in text
print(text)
虫食い虫食い・復習 2 JSON文字列を読む

JSON文字列から辞書へ戻します.size が40になる関数を補ってください.

fill_review_json_project_2.py
import json
text = '{"size": 40}'
data = json.____(text)
assert data["size"] == 40
print(data)
虫食い虫食い・復習 3 ファイルへ保存する

UTF-8でJSONを書き,読み直して一致を確かめます.書き込みモードと保存する関数を補ってください.

fill_review_json_project_3.py
import json
data = {"version": 1, "shapes": []}
with open("fill25_save.json", ____, encoding="utf-8") as file:
    json.____(data, file, ensure_ascii=False)
with open("fill25_save.json", encoding="utf-8") as file:
    assert json.load(file) == data
print("保存を確認しました")
虫食い虫食い・復習 4 ファイルから復元する

練習用ファイルを読み,図形の大きさ30を取り出します.読み込みモードと読み込む関数を補ってください.

fill_review_json_project_4.py
import json
with open("fill25_load.json", "w", encoding="utf-8") as file:
    json.dump({"shapes": [{"size": 30}]}, file)
with open("fill25_load.json", ____, encoding="utf-8") as file:
    data = json.____(file)
assert data["shapes"][0]["size"] == 30
print(data)
虫食い虫食い・復習 5 形式の版を検証する

対応する version は1だけです.版2を拒否する条件を補ってください.

fill_review_json_project_5.py
def validate(data):
    if data.get("version") ____ 1:
        raise ValueError("対応していない版")
    return data
assert validate({"version": 1}) == {"version": 1}
try:
    validate({"version": 2})
except ValueError:
    print("版2を拒否しました")
else:
    assert False
虫食い虫食い・復習 6 辞書からインスタンスを作る

JSONを読んで得た辞書の size を,生成する図形へ渡します.属性が50になることを確認してください.

fill_review_json_project_6.py
import json
class Shape:
    def __init__(self, size):
        self.size = size
data = json.loads('{"size": 50}')
shape = Shape(____)
assert shape.size == 50
print(shape.size)

ここから先へ

第 26 章の自由制作で,ここまでの道具を組み合わせて作品を仕上げます.ここから先は,作りたいものを決めて,必要な道具を自分で探す番です.調べ方はもう知っています.行きづまったら,速度を落として,少しずつ書いて,print で覗いてみる.やり方は第 2 章から何も変わりません.よい旅を.