件数が合わない — 行ではなくレコードを数える
目標
顧客のCSVを、行ではなくレコードとして数えるゲートキーパーcsvgate.pyを作ります。手で分割したパーサーと標準のパーサーの答えがどれだけ開くかを自分の資料で測り、区切り文字・行末・ヘッダーの有無を判別し、使えない行をリジェクトファイルに切り離します。
なぜ重要なのか
CSVでは、行とレコードは別のものです。注文メモに改行があると、レコード1つがファイルの中で2行を占め、社名にカンマがあると、その行をカンマで分割したときにカラムが増えます。どちらの場合も、パーサーはエラーを出さず、もっともらしい数字を出します。そのため、この間違いは、顧客が数字を見ておかしいと言うまで生き残ります。 RFC 4180は、引用符のルールと行末を定めていますが、規格をすべて守ったファイルだけが届くわけではありません。区切り文字がセミコロンやタブで、行末が1つのファイルの中で混ざっていて、ヘッダーがないファイルが届きます。ヘッダーがあるかどうかはファイルの中に書かれていないので、判別する必要があります。 そして、使えない行をどう扱うかが残ります。静かに飛ばすと、その件数の分だけ売上が消え、説明する根拠がありません。銀行の受信ファイルなら、1行だけずれてもファイルをまるごと送り返しますが、顧客が渡した分析用の資料は、送り返す先がありません。使える行は使い、使えない行だけを切り離します。 採点ツールは、提出された文言を信じません。一時ディレクトリに、採点ツールが作ったフィードを用意し、作成したゲートキーパーを実際に実行して、レコード数・金額の合計・リジェクトの理由を、採点ツールが自分で数えた値と照合します。社名と金額は、実行ごとに変わります。
ステップ
- /root/csv/gen_feed.pyを作成して実行し、feedディレクトリの下に7つのファイルを作成してください(保存先: /root/csv/feed)。同じ注文20件が、7つの形で出てきます。
- /root/csv/csvgate.pyに
naiveを作成し、行1つをレコード1つと信じるパーサーの答え(rows・amount_total・bad_field_count)を出力させてください。 parseを追加して、csvモジュールで正しく数えた答え(records・amount_total)を出力させ、2つの答えの差を書いてください(保存先: /root/csv/gap.json)。sniffを追加して、行末をCRLF・LF・MIXEDで判別させてください。sniffが、区切り文字を候補(カンマ・セミコロン・タブ・パイプ)の中から判別するようにしてください。sniffが、ヘッダーの有無(header)とカラム数(fields)を判別するようにしてください。checkを追加して、使えない行を理由別に数え、リジェクトファイルに切り離させてください。リジェクトファイルは、入力ファイルがあるディレクトリの下のrejects/に、同じ名前で書きます。- フィードの7つのファイルを一度に処理して、/root/csv/feed_report.jsonと、/root/csv/feed_report.mdを作成してください。
参考
- 実行契約:
python3 /root/csv/csvgate.py <명령> <파일>(プレースホルダーは順に、コマンドとファイルです)。コマンドはnaive・parse・sniff・checkの4つです。成功すれば終了コード0、ファイルがなければ3、使い方が間違っていれば2です。答えは、JSONの1つの塊として標準出力に出します。 naiveの応答:{"rows": 정수, "amount_total": 정수, "bad_field_count": 정수}(プレースホルダーは整数です)。最初の行はヘッダーとみなして飛ばし、空行も数えず、カンマで分割したカラムが5つでなければbad_field_countとして数えます。parseの応答:{"records": 정수, "amount_total": 정수}(プレースホルダーは整数です)。sniffの応答:{"path": 문자열, "delimiter": 문자열, "newline": "CRLF"|"LF"|"MIXED"|"NONE"}(プレースホルダーは文字列です)に、ステップ6からfields・headerが、ステップ7からunterminatedが加わります。checkの応答:{"file": 이름, "delimiter": 문자열, "records": 정수, "accepted": 정수, "rejected": 정수, "amount_total": 정수, "reasons": {"field_count": 정수, "empty": 정수, "unterminated": 정수}}(プレースホルダーは順に、名前、文字列、整数です)。- リジェクトファイルは、ヘッダー
reason,record_no,fieldsで始まり、record_noは、ヘッダーを除いて何番目のレコードか(1から)です。 - Pythonのcsvモジュールでファイルを開くときは、
open(path, encoding="utf-8", newline="")です。newline=""を抜かすと、引用符の中の改行がレコードを切ります。 - ヘッダーの判別は、このラボの前提を使います。最初の行には数字として読めるカラムが1つもなく、続く3行にはあれば、ヘッダーとみなします。資料が変われば、この基準も決め直す必要があります。
- 区切り文字の判別も前提です。候補ごとにファイル全体をパースしてみて、カラム数が最も均一に広い候補を選びます。
- 公式ドキュメント: RFC 4180・python csv・python json
- よくあるミス:
wc -lで件数を報告すること、newline=""を抜かすこと、使えない行を静かに飛ばすこと、閉じられていない引用符をエラーと勘違いすること(パーサーは静かです)。 - 行末を目で見たいときは、
od -c <파일> | headを使ってください(プレースホルダーはファイルです)。
7つの形の同じフィードを作る
/root/csv/gen_feed.pyを作成して実行し、feedディレクトリの下に7つのファイルを作成してください(保存先: /root/csv/feed)。同じ注文20件が、区切り文字・行末・ヘッダー・破損の有無だけを変えて出てきます。
Pythonのcsvモジュールのwriterで書けば、引用符は自動で付きます。lineterminatorを変えて行末を選び、delimiterを変えて区切り文字を選びます。わざと壊すファイル(カラム数が違う行、閉じられていない引用符)は、writerを使わず、文字列で直接書きます。
手で分割したパーサーの答えを出す
/root/csv/csvgate.pyにnaive <파일>を作成し(プレースホルダーはファイルです)、行1つをレコード1つと信じるパーサーの答えをJSONで出力させてください。rows・amount_total・bad_field_countの3つの値です。
このステップは、わざと間違ったパーサーを作るものです。ファイルをまるごと読んで改行で分割し、最初の行と空行を飛ばし、各行をカンマで分割します。カラムが5つでなければ、金額を足さず、bad_field_countとしてだけ数えてください。あとで正しく数えた答えと並べるので、ルールを正確に守ります。
引用符の中のカンマと改行を正しく読む
parse <파일>を追加して(プレースホルダーはファイルです)、csvモジュールで数えた答え(records・amount_total)を出力させ、orders_rfc.csvに対する2つの答えの差を、file・naive_rows・csv_records・naive_amount・csv_amountとして書いてください(保存先: /root/csv/gap.json)。
ファイルを開くときにnewline=""を指定することが核心です。csv.readerは、引用符の中の区切り文字と改行、2つで書かれた引用符をすべて処理します。ヘッダー行を除いた残りがレコードで、金額は、カラム数が合っているレコードでだけ足します。
行末が混ざったファイルを見分ける
sniff <파일>を追加して(プレースホルダーはファイルです)、行末を判別させてください。CRLFだけならCRLF、LFだけならLF、2つが混ざっていればMIXEDです。応答には、path・delimiter・newlineが必要です。
行末はバイトで数えます。ファイルをバイナリで読んでCRLFの個数を数え、全体のLFの個数からその分を引けば、単独のLFの個数が出ます。両方が0でなければ、混ざっています。このステップでは、区切り文字はカンマにしておいてもかまいません。
区切り文字を候補の中から選ぶ
sniffが、区切り文字をカンマ・セミコロン・タブ・パイプの4つの候補の中から判別するようにしてください。候補ごとにファイル全体をパースしてみて、カラム数が最も均一に広いものを選びます。
最初の行だけを見て数えると、引用符の中の区切り文字にだまされます。候補ごとにcsv.readerでまるごとパースして、カラム数の最頻値と、その最頻値を持つ行の割合を求め、カラムが1つだけの候補は点数を0にしてください。カラム数が行ごとに違うファイルでも、この方法は耐えられます。
ヘッダーがあるかないかを判別する
sniffの応答にfields(カラム数)とheader(ヘッダーの有無)を加えてください。最初の行には数字として読めるカラムが1つもなく、続く3行にはあれば、ヘッダーとみなします。
ヘッダーがあるかどうかは、ファイルの中に書かれていません。RFC 4180も、それをメディアタイプのパラメーターで知らせるよう言っているだけです。そのため、判別ではなく推定であり、推定には基準が必要です。基準をコードに書いておけば、次の納品で間違ったときに、何を直せばよいかが見えます。
使えない行だけを切り離す
check <파일>を追加して(プレースホルダーはファイルです)レコードごとに検査し、空行・カラム数の不一致・閉じられていない引用符を理由別に数え、リジェクトファイルに切り離させてください。リジェクトファイルは、入力ファイルがあるディレクトリのrejects/に、同じ名前で書きます。sniffの応答にはunterminatedを加えます。
閉じられていない引用符は、エラーを出しません。その地点からファイルの終わりまでが1つのフィールドになり、「最後の1件」のように見えるだけです。ファイルを一度たどって、引用の状態で終わっていないかを見れば、確実にわかります。2つで書かれた引用符は飛ばす必要があります。リジェクトした行には、元が何番目のレコードだったかも一緒に書いてください。
フィード1枚で報告する
フィードの7つのファイルを一度に処理して、files・accepted・rejected・amount_totalを書き(保存先: /root/csv/feed_report.json)、/root/csv/feed_report.mdに、## 무엇을 받았나、## 줄과 레코드는 다르다、## 떼어 낸 줄、## 보내는 쪽에 요청할 것の4つの節で書いてください(韓国語の見出しは順に「受け取ったもの」「行とレコードは別のもの」「切り離した行」「送る側に依頼すること」という意味です)。
filesは、ファイル名をキーにして、delimiter・newline・header・records・accepted・rejected・amount_totalを入れたオブジェクトです。レポートには、切り離した行の数を数字で書いてください。顧客が自分のファイルでその行を開いて見られれば、会話が速くなります。前のステップで作った関数を、そのまま呼べば足ります。