라이프스타일

실습: CSV 정리 도구 만들기

입력을 보존하고 새 파일로 내보내는 CSV 정리 도구를 만듭니다. 공백 제거, email 소문자화, 중복 중 첫 유효 행 유지, 빈 이름이나 잘못된 형식 제외 후 세 종류의 건수를 보고합니다. 실제 이메일 존재를 보장하지는 않습니다.

읽는 데 약 20분 · 실습 45 분

작업 흐름을 설명하는 그림이며 제품 스크린샷이 아닙니다.
사진: Mokaair (© Mokaair)
이 글의 목차
  1. 목표와 준비
  2. 1단계: 손으로 검산할 데이터 만들기
  3. 2단계: 정리 계약 먼저 계획
  4. 3단계: 참고 구현의 보호 지점 확인
  5. 4단계: 실행·상태 저장·출력 확인
  6. 5단계: Unicode·따옴표·잘못된 행
  7. 6단계: 재실행 가능한 검증 보존
  8. 전달·복원·제한

목표와 준비

1단계: 손으로 검산할 데이터 만들기

csv-lab을 열어 아래를 UTF-8 contacts.csv로 저장합니다. 헤더는 순서까지 name,email과 같아야 하며 Alice와 첫 email 앞뒤 공백을 시험 조건으로 유지하세요. 스프레드시트 재저장으로 인코딩·값이 바뀌지 않게 텍스트 편집기로 확인합니다. Windows는 py -3 --version, macOS/Linux는 python3 --version으로 Python 3.9 이상을 확인하며 pip·pandas·데이터베이스는 필요 없습니다.

contacts.csv (시험 공백 유지) · csv
name,email
 Alice , ALICE@EXAMPLE.TEST 
Alice duplicate,alice@example.test
Bob,bob@example.test
Missing,
입력 행판정출력
Alice / ALICE@EXAMPLE.TEST유효·공백 제거·소문자화Alice / alice@example.test
Alice duplicate / alice@example.test유효 email 중복제외
Bob / bob@example.test유효Bob / bob@example.test
Missing / 빈 email무효제외

kept 2·duplicate 1·invalid 1을 기대합니다. 이름이 아니라 정규화한 email로 중복을 판정합니다. 같은 주소의 앞 행이 빈 이름으로 무효라면 뒤의 첫 유효 행을 유지할 수 있습니다. 생성 전에 이 규칙을 명확히 하세요.

2단계: 정리 계약 먼저 계획

Codex에서 csv-lab을 열고 로 입출력·예외·검증을 확인한 뒤 구현합니다. 출력은 매번 새 파일이며 구조 손상은 성공처럼 보이는 일부 결과를 남기지 않고 전체 실패해야 합니다. 무효 email은 집계 가능한 자료 문제지만 필드 부족·추가·헤더 오류는 계약 위반입니다. 후속 사용 가능성에 영향을 주므로 단순히 CSV 정리라고만 요청하지 마세요.

구현 요청 · text
Create clean_contacts.py using Python's standard library only.
Expose clean(source: Path, target: Path) returning kept/duplicate/invalid counts.
Read UTF-8 with optional BOM using csv.DictReader, not string splitting.
Require exactly name,email. Trim both fields and lowercase email.
Keep the first valid row per email; reject empty names and invalid email shapes.
Treat missing/extra columns or malformed CSV as whole-run failures.
Read and validate before creating output. Never change input or overwrite output.
Write UTF-8 with name,email and a newline after each row.
CLI takes source and target paths, reports counts on stderr and exits 0 on success,
1 on data/file errors. Add tests for preservation, duplicates, Unicode, quoted
commas, malformed rows and overwrite refusal. Do not use real contact data.

3단계: 참고 구현의 보호 지점 확인

아래는 clean_contacts.py로 독립 실행하는 전체 참고 코드입니다. 생성본은 별도 보관한 뒤 비교하세요. csv 모듈이 따옴표·쉼표·줄바꿈을 처리하며 split(',')로 대체할 수 없습니다. 전체 입력을 읽은 뒤 x로 새 출력을 만들고 기존 대상은 거부합니다. 입력 오류에는 출력이 없지만 쓰는 도중 디스크 오류는 일부 파일을 남길 수 있어 실패로 기록하고 조사해야 합니다. 완전한 트랜잭션 시스템이라고 부르지 않습니다.

clean_contacts.py (전체 참고 코드) · python
"""Clean a practice CSV without overwriting either the input or an existing output."""
import argparse
import csv
import json
import re
import sys
from pathlib import Path


def clean(source: Path, target: Path) -> dict[str, int]:
    if source.resolve() == target.resolve():
        raise ValueError("Input and output must differ")
    rows = []
    seen = set()
    counts = {"kept": 0, "duplicate": 0, "invalid": 0}
    with source.open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, strict=True)
        if reader.fieldnames != ["name", "email"]:
            raise ValueError("Expected exactly: name,email")
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError("Malformed CSV row")
            name = row["name"].strip()
            email = row["email"].strip().lower()
            # An exercise-level shape check, not proof an address can receive mail.
            if not name or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email):
                counts["invalid"] += 1
            elif email in seen:
                counts["duplicate"] += 1
            else:
                seen.add(email)
                rows.append({"name": name, "email": email})
                counts["kept"] += 1
    # Exclusive creation: a rerun never silently replaces an existing result.
    with target.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=["name", "email"], lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
    return counts


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path)
    parser.add_argument("target", type=Path)
    args = parser.parse_args()
    try:
        counts = clean(args.source, args.target)
    except (OSError, ValueError, csv.Error) as error:
        print(str(error), file=sys.stderr)
        return 1
    print(json.dumps(counts), file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

4단계: 실행·상태 저장·출력 확인

cleaned-01.csv가 없는지 확인합니다. macOS/Linux의 contacts-before.csv도 사용하지 않은 이름이어야 cp가 이전 백업을 덮어쓰지 않습니다. PowerShell은 입력 SHA256, macOS/Linux는 가상 입력 사본과 cmp를 사용합니다. 성공 시에도 개수 JSON이 stderr에 나오므로 해당 채널의 텍스트만으로 실패라 판단하지 마세요. 종료 상태를 즉시 저장하고 입출력을 비교합니다.

Windows PowerShell · powershell
$csvBefore = (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
py -3 clean_contacts.py contacts.csv cleaned-01.csv
$csvExit = $LASTEXITCODE
$csvExit
Get-Content -LiteralPath cleaned-01.csv
$csvBefore -eq (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
macOS / Linux · sh
cp contacts.csv contacts-before.csv
python3 clean_contacts.py contacts.csv cleaned-01.csv
csv_exit=$?
echo "$csv_exit"
cat cleaned-01.csv
cmp contacts-before.csv contacts.csv
cleaned-01.csv 예상 내용 · csv
name,email
Alice,alice@example.test
Bob,bob@example.test

종료 0, kept 2·duplicate 1·invalid 1, 원래 순서의 두 행, True 또는 cmp 차이 없음을 기대합니다. 같은 명령을 반복하면 기존 cleaned-01.csv 때문에 종료 1이며 내용은 그대로여야 합니다. 먼저 삭제하고 덮어쓰기 보호를 통과했다고 하지 마세요. 입출력을 모두 contacts.csv로 지정해도 원본 불변으로 거부해야 합니다. 예상 실패도 검증 통과의 일부입니다.

5단계: Unicode·따옴표·잘못된 행

아래 contacts-unicode.csv를 만들어 cleaned-unicode.csv로 출력합니다. kept 1·duplicate 0·invalid 0이며 이름의 쉼표는 한 필드 안에 있고 Unicode가 유지되어야 합니다. 다시 열 때 깨지면 입력을 고치기 전에 UTF-8 해석을 확인하세요. bad.csv는 name,email 다음 줄에 Alice만 써 email 필드를 누락시킵니다. 새 cleaned-bad.csv가 생성되지 않고 종료 1이어야 합니다. 뒤집은 헤더·세 번째 필드 추가도 거부해야 합니다.

contacts-unicode.csv · csv
name,email
"林, Alex",alex@example.test

확장: 첫 번째 유효 행 보존

contacts-first-valid.csv · csv
name,email
 ,first@example.test
First valid,FIRST@example.test
Later duplicate,first@example.test

같은 프로그램에 새 입력과 아직 없는 cleaned-first-valid.csv를 지정합니다. kept 1, duplicate 1, invalid 1이 기대값입니다. 빈 이름의 무효 행이 email을 선점하면 안 되며 First valid가 첫 유효 행이고 마지막 행이 중복입니다. First valid가 없으면 검증 후 seen에 추가하는지 확인하세요. contacts.csv와 cleaned-01.csv는 그대로 유지합니다.

cleaned-first-valid.csv 예상 내용 · csv
name,email
First valid,first@example.test

6단계: 재실행 가능한 검증 보존

아래를 프로그램·contacts.csv 옆의 test_clean_contacts.py로 저장합니다. 임시 폴더에서 입력 바이트 불변·덮어쓰기 거부·Unicode·따옴표·구조 오류를 검증해 연습 폴더에 결과를 쌓지 않습니다. Windows는 py -3 -m unittest -v test_clean_contacts.py, macOS/Linux는 python3 -m unittest -v test_clean_contacts.py로 참고 시험 세 개 통과를 확인하세요. 생성본 인터페이스가 다르면 clean 계약을 맞추고 실패 사례를 삭제하지 않습니다.

test_clean_contacts.py · python
import csv
import tempfile
import unittest
from pathlib import Path

from clean_contacts import clean


class CleanupTests(unittest.TestCase):
    def test_contract_and_input_preservation(self):
        original = (Path(__file__).parent / "contacts.csv").read_bytes()
        with tempfile.TemporaryDirectory() as directory:
            source = Path(directory) / "input.csv"
            target = Path(directory) / "output.csv"
            source.write_bytes(original)
            self.assertEqual(clean(source, target), {"kept": 2, "duplicate": 1, "invalid": 1})
            self.assertEqual(source.read_bytes(), original)
            with target.open(encoding="utf-8", newline="") as stream:
                self.assertEqual(list(csv.DictReader(stream)), [{"name": "Alice", "email": "alice@example.test"}, {"name": "Bob", "email": "bob@example.test"}])
            with self.assertRaises(FileExistsError):
                clean(source, target)
            with self.assertRaises(ValueError):
                clean(source, source)

    def test_unicode_and_quoted_comma(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            source.write_text('name,email\n"林, Alex",alex@example.test\n', encoding="utf-8")
            self.assertEqual(clean(source, target)["kept"], 1)
            self.assertIn('"林, Alex"', target.read_text(encoding="utf-8"))

    def test_bad_header_and_malformed_rows_create_no_output(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            for value in ["email,name\na@b.test,A\n", "name,email\nAlice\n", "name,email\nA,a@b.test,extra\n"]:
                source.write_text(value, encoding="utf-8")
                with self.assertRaises(ValueError):
                    clean(source, target)
                self.assertFalse(target.exists())


if __name__ == "__main__":
    unittest.main()

전달·복원·제한

가상 입력·프로그램·시험·명령·집계·원본 보존 증거를 전달합니다. CSV 전체 자료는 참고 코드가 있어 이전 프로젝트 없이 비교할 수 있습니다. 재정리는 새 출력 이름, 정리는 확인한 결과만 선택합니다. 실제 연락처·대용량·스프레드시트 가져오기는 권한·스트리밍·수식 문자열 규칙을 별도로 설계하세요. 이 편은 텍스트로 저장하며 외부 내용을 명령으로 실행하지 않습니다. 같은 기준·검증법으로 로 진행합니다.

32. 실습: CSV 정리 도구 만들기 — 작업 흐름을 설명하는 그림이며 제품 스크린샷이 아닙니다. contacts.csv → Cleanup → clean.csv
32. 실습: CSV 정리 도구 만들기 — 작업 흐름을 설명하는 그림이며 제품 스크린샷이 아닙니다. contacts.csv → Cleanup → clean.csv · 사진: Mokaair (© Mokaair)
자세한 설명 보기

contacts.csv to Cleanup to clean.csv

전체 목차

  • 라이프스타일

    Codex 학습 센터: 전체 튜토리얼 목차

    설치와 첫 작업부터 MD 지침과 고급 연동까지 60개 강의, 열 개 단원을 계획합니다. 수준, 환경, 목표, 명령으로 다음 글을 찾고 미게시 항목의 상태를 확인할 수 있습니다.

  • 라이프스타일

    Worktree와 작업 격리

    Worktree는 하나의 Git 저장소에 다른 브랜치의 작업 폴더를 만듭니다. 파일이 분리되어도 DB, 포트와 외부 서비스는 공유될 수 있습니다.

  • 라이프스타일

    실습: 작은 웹사이트 만들기

    brief.md에서 Small Steps 할 일 사이트를 계획하고 추가·완료·삭제·필터·로컬 저장을 구현합니다. HTML·CSS·데이터 함수·화면 이벤트·시험을 분리하고 Node와 브라우저로 검증한 뒤 재시작·복원 인계 기록을 남깁니다.

  • 라이프스타일

    사용량과 효율: 재작업 줄이기

    조건, 모델 선택, 시간, 결과를 기록해 불필요한 재시도와 과도한 문맥을 줄입니다.

최신 여행 소식·가이드

출처

라이프스타일