薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

Pythonでダブルクォーテーション囲いのCSVファイルを作成する

調剤薬局に勤めている友人から、「納品価格が更新された在庫薬のCSVファイルを新たにレセコンに取り込みたいが、うまくいかない」との相談を受け、Pythonプログラムを書いて実現することにしました。わざわざPythonを持ち出さなくても他にも方法はありますが、プログラミング習作の1つとしてやってみました。


CSVファイルとは

「Comma Separated Values」の略称でその名の示す通り、値をカンマ(,)で区切って並べた汎用的なファイル形式のことです。様々なソフトで開いたり、編集することができデータの受け渡しなどでよく使われます。
ただ、使うシステムによっては、区切り文字がカンマではなくタブだったり、値の括りがシングルクオーテーション(')ではなくダブルクォーテーション(")だったりと、若干形式が異なる(いわゆる「方言」と言われる)場合があります。
そのためデータをCSV形式でやりとりする際には、システムが要求する仕様に合わせてファイルを作成する必要があります。

出力ファイル仕様

今回受け取り側のシステムが要求するCSVファイルの仕様は以下の通りです。
・出力項目

No. 項目名 属性
1 JANコード 文字
2 包装単価 数字
3 商品名 文字

・カンマ区切り
・「属性」が"文字"の項目は文字列を半角の二重引用符(")で括る
・「包装単価」は小数点以下2桁まで取り込まれる
・JANコードは重複しないようにすること。重複が存在する場合は最後のレコードが取り込まれる

<出力イメージ>
f:id:enokisaute:20201114124720p:plain

作成するプログラム

細かい点は他にもありましたが、とりあえず以下の2点を実現できるようなプログラムを書いていきます。
1.データにJANコードの重複がないかをチェックする
2.属性に応じて項目を適切にダブルクォーテーションで括る

元となるデータは既に各卸さんに頼むなどしてJANコード、包装単価、商品名の3項目のリストを(ダブルクォーテーション囲いではない)CSVファイルで得ているものとします。ただし、これらには重複があります。
JANコードに重複があった場合は、先のレコードがなかったことにされてしまうので、先にこれを確認します。また、1と2はいっぺんに書くとコードがわかりにくくなるので、それぞれ別のプログラムとして書くことにしました。

PythonでのCSVファイルの読み書きの基本

データ分析でよく使われるpandasを使う方法もありますが、今回は標準ライブラリのcsvモジュールを使用します。
csv --- CSV ファイルの読み書き — Python 3.9.2 ドキュメント

読み込み
import csv
# エンコードを指定して読み込みモードでCSVファイルを開く
csvfile = open('test.csv', 'r', encoding='cp932')
for row in csv.reader(csvfile):
    # 何らかの処理
csvfile.close()

open()関数でCSVファイルを開き、ファイルオブジェクトをcsv.reader()関数に渡します。この関数はreaderオブジェクトを返し、これをforループで用いるとCSVファイルの行を1行ずつ順番に読み込んで処理を進めることができます。
なお、商品名にはWindowsの拡張文字(髙, 蓮など)が入っている場合があるので、エンコードは'shift-jis'ではなく'cp932'を指定します。

書き出し
import csv
# エンコードを指定して書き込み(上書き)モードでCSVファイルを開く
outputfile = open('output.csv', 'w', encoding='cp932', newline='')
writer = csv.writer(outputfile)
 
writer.writerow(['111', '500', 'りんご'])  # 111, 500, りんご
writer.writerow((222, 'バナナ'))   # 222, バナナ
writer.writerow('apple')        # a, p, p, l, e
outputfile.close()

csv.writer()関数で作られるwriterオブジェクトのwriterowメソッドを用いると、CSVファイルに書き込むことができます。writerowメソッドの引数にリストや文字列などのシーケンス型を渡すと、要素を設定(後述)に従って区切って書き出してくれます。
また、Windowsではopen()関数の引数newlineに空文字列を渡して改行コードが変更されないようにします。この指定を忘れると出力ファイルに1行おきに空行が入ってしまいます。

なお、open関数のところは次のようにwith文を使うとより簡潔に書くことができます。

import csv
with open(output.csv, 'w', encoding='cp932', newline='') as f:
    writer = csv.writer(f) 
    # 何らかの処理

ファイルを開くことに成功すれば「as」の次にくる変数にファイルオブジェクトが代入され、下のブロックに処理が移ります。失敗した場合はブロックの中の処理は実行されません。ブロックから抜けるときにファイルが閉じられるので、close()を書く必要がありません。

CSVファイルから重複しているデータをすべて書き出す

重複するデータは一つ一つのファイル毎ではなく、同一フォルダ内の複数のCSVファイル間にまたがってチェックして書き出すようにします。
f:id:enokisaute:20201121204602p:plain

書き出す項目は以下のものです。
・JANコード、価格、商品名、ファイル名、行番号

重複しているJANコードだけが知りたいのであれば、データを新しく読み込む毎に辞書に存在するかどうかを確認して処理を分けるだけで良さそうです。次のようなイメージです。

forin CSVファイル:    # 対象のファイルを1行ずつ読み込む
    if key in 辞書:    # 読み込んだ行データが辞書に存在するか
        # あれば重複データとしてファイルに書き出し
    辞書 ← データ    # 辞書にデータを追加

しかし、これだと例えば2つの重複するデータがあっても、最初に辞書に登録されたデータは書き出しされず、後の方のデータ(片方)しか書き出しできません。
今回は重複があるものはすべて書き出ししたいので、最初に辞書に加えられたデータも書き出しするようにします。

forin CSVファイル:   # 対象のファイルを1行ずつ読み込む
    if key in 辞書:    # 読み込んだ行データが辞書に存在するか
        # あれば重複データとしてファイルに書き出し
        if 辞書データは"処理済"でない:
            # 辞書データも書き出し
            # 辞書の値(の一部)を"処理済"に変更する
    else:
        辞書 ← データ    # 辞書にデータを追加

2つ目のif文の「辞書のデータは処理済(既に書き出ししている)か」の判定は、辞書の値として持つリストの要素の1つ(行番号)が”処理済”の定数であるかどうか、で行うようにしました。
プログラムの実行後は、出来たファイルをみて重複を確認し、JANコードの重複がないように各CSVファイルを編集していきます。
以下がコードです。このpyファイルをCSVファイルと同じ階層に置いて実行します。

import csv
import glob
import os
import unicodedata
 
# プログラム1
# 同一フォルダ内の全てのCSVファイルを読み込み, JANコード(先頭列)が重複している
# データを全てファイルに書き出す. なお, CSVファイルにヘッダーはないものとする.
 
# 全角半角を正規化してスペースを全て削除する
def normalize(value):
    return unicodedata.normalize('NFKC', value).replace(' ', '')
 
current_dir = os.path.abspath('.')
csvfiles = glob.glob(current_dir + '\\*.csv')
check_f = open(current_dir + '\\check.csv', 'w', encoding='cp932', newline='')  # 出力ファイル
writer = csv.writer(check_f)
 
JAN_CODE = 0        # 読み込んだ行のインデックスとして用いる
PRICE = 1
NAME = 2
check_dict = {}     # 重複チェックのための辞書
PROCESSED = -1      # 処理済み定数. 書き出した辞書の値を書き換える
 
writer.writerow(['JANコード', '包装単価', '品名', 'ファイル名', '行番号'])   # ヘッダー
 
for readfile in csvfiles:
    f_in = open(readfile, 'r', encoding='cp932')
    row_num = 1     # 行番号
    for row in csv.reader(f_in):
        j_code = normalize(row[JAN_CODE])
        price = row[PRICE]
        y_name = row[NAME]                     # 薬品名
        f_name = os.path.basename(readfile)    # ファイル名
        # 辞書に同じkey(JANコード)があればデータを書き出す
        if j_code in check_dict:
            writer.writerow([j_code, price, y_name, f_name, row_num])
            # 辞書の中のデータを書き出す. これは1回だけで良い
            if check_dict[j_code][-1] != PROCESSED:
                writer.writerow(list(check_dict[j_code]))
                check_dict[j_code][-1] = PROCESSED   # 行番号を処理済定数に書き換えておく
        else:
            # 重複していないデータのみ辞書に登録する
            check_dict[j_code] = [j_code, price, y_name, f_name, row_num]
        row_num += 1
    f_in.close()
check_f.close()


ダブルクォーテーション(二重引用符)囲いに変換する

ここまでの処理で対象のすべてのCSVファイルにはJANコードの重複がないようにされているので、あとは属性に応じて項目をダブルクォーテーションで括って、一つのCSVファイルとして出力するだけです。
上の方でcsv.writerは設定に従って・・・と書きましたが、オプションのキーワード引数を与えることで、書き出しされるCSVファイルの書式を自由に設定することができます。
キーワード引数にはいくつかありますが、今回は次のように指定しました。

writer = csv.writer(output_f,
                    delimiter=',',  # 区切り文字はカンマ
                    quotechar='"',  # 囲い文字はダブルクォーテーション
                    quoting=csv.QUOTE_NONNUMERIC)    # 全ての非数値フィールドをクオート

delimiterは'\t'ならタブ区切り、' 'ならスペース区切り、などとすることができ、quotecharでは囲い文字を指定できます。なお、区切り文字のカンマ、囲い文字のダブルクォーテーションはデフォルト値なので本来はわざわざ指定する必要はありません。
quotingには以下の4つが定数として定義されています。

csv.QUOTE_ALL
全てのフィールドをクオート
csv.QUOTE_MINIMAL
任意の特別文字を含むフィールドをクォート(デフォルト)
csv.QUOTE_NONNUMERIC
全ての非数値フィールドをクオート
csv.QUOTE_NONE
クォートしない

csv --- CSV ファイルの読み書き — Python 3.9.2 ドキュメント

CSVファイルから読み込まれた各行は、読み込み時にオプションが指定された場合を除き、文字列のリストとして返されます。「包装単価」は属性が"数字"でクォートしないので、この項目はfloatに変換してからwriterowメソッドに渡してやります。

import csv
import glob
import os
import unicodedata
 
# プログラム2
# 同一フォルダ内の全てのCSVファイルを読み込み, 包装価格以外の項目を
# ダブルクォーテーションで囲いCSVファイルに書き出す.
 
# 全角半角を正規化してスペースを全て削除する
def normalize(value):
    return unicodedata.normalize('NFKC', value).replace(' ', '')
 
current_dir = os.path.abspath('.')
csvfiles = glob.glob(current_dir + '\\*.csv')
output_f = open(current_dir + '\\output.csv', 'w', encoding='cp932', newline='')  # 出力ファイル
 
writer = csv.writer(output_f,
                    delimiter=',',  # 区切り文字をカンマで指定
                    quotechar='"',  # 囲い文字をダブルクォーテーションで指定
                    quoting=csv.QUOTE_NONNUMERIC)   # 全ての非数値フィールドをクオート
 
JAN_CODE = 0    # 読み込んだ行のインデックス
PRICE = 1
NAME = 2
 
for readfile in csvfiles:
    with open(readfile, 'r', encoding='cp932') as f:
        for row in csv.reader(f):
            j_code = normalize(row[JAN_CODE])
            price = float(normalize(row[PRICE]).replace(',', ''))  # カンマを取り除いてfloatに変換
            y_name = normalize(row[NAME])
            writer.writerow([j_code, price, y_name])
output_f.close()

テストデータで作成されたファイルをメモ帳で開くと以下のようなかんじです。
f:id:enokisaute:20201122153208p:plain
うまくできました。

参考

・みんなのPython 第3版
・退屈なことはPythonにやらせよう ―ノンプログラマーにもできる自動化処理プログラミング