Практика · CSV і JSON¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Наскрізний приклад той самий, що в лекції, — маленька кавʼярня. Меню поїде в CSV (бо це таблиця), замовлення — у JSON (бо в ньому вкладений список позицій).

Що зробимо:

  1. створимо тимчасову теку й покладемо в неї всі файли;
  2. запишемо меню в CSV через DictWriter і прочитаємо назад через DictReader;
  3. переконаємось, що split(",") ламає рядок, а модуль csv — ні;
  4. перетворимо типи: з CSV усе приходить рядками;
  5. запишемо замовлення в JSON з ensure_ascii=False і побачимо, що кортеж став списком;
  6. зловимо дві поломки: обірваний JSON і CSV із пропущеним стовпцем;
  7. порівняємо розмір CSV і JSON на однакових даних;
  8. приберемо за собою.

Мережа не потрібна: усі дані ми вигадуємо самі, усі файли живуть у тимчасовій теці, яку наприкінці видаляємо.

1 · Тимчасова тека¶

tempfile.mkdtemp() створює порожню теку в системному місці для тимчасових файлів і повертає шлях до неї. Так ми нічого не насмітимо в робочій теці — і в кінці приберемо все одним рухом.

In [1]:
import csv
import json
import shutil
import tempfile
from pathlib import Path

# окрема тека на цей запуск: файли не змішаються з чужими
тека = Path(tempfile.mkdtemp(prefix="kavyarnia-"))
print("працюємо в:", тека)
print("тека порожня:", list(тека.iterdir()) == [])
працюємо в: /tmp/kavyarnia-vta7hjoy
тека порожня: True

2 · Дані меню¶

Звичайний список словників — те, як дані зазвичай і живуть у програмі. Зверни увагу на опис капучино: у ньому є кома. Саме вона зробить усю подальшу роботу цікавою.

In [2]:
меню = [
    {"назва": "Еспресо",  "ціна": 25, "категорія": "кава", "опис": "міцна"},
    {"назва": "Капучино", "ціна": 45, "категорія": "кава", "опис": "молоко, кориця"},
    {"назва": "Латте",    "ціна": 50, "категорія": "кава", "опис": 'сироп "Карамель"'},
    {"назва": "Чай",      "ціна": 30, "категорія": "чай",  "опис": "травʼяний"},
]

print("напоїв у меню:", len(меню))
for напій in меню:
    print(" ", напій["назва"], "—", напій["ціна"], "грн ·", напій["опис"])
напоїв у меню: 4
  Еспресо — 25 грн · міцна
  Капучино — 45 грн · молоко, кориця
  Латте — 50 грн · сироп "Карамель"
  Чай — 30 грн · травʼяний

3 · Запис у CSV через DictWriter¶

Три речі, які тут обовʼязкові й пояснені в лекції:

  • newline="" — щоб модуль сам вирішував, чим завершувати рядок;
  • encoding="utf-8" — щоб кирилиця не залежала від налаштувань системи;
  • fieldnames — порядок стовпців задаємо ми, а не випадок.

Одразу після запису надрукуємо файл як текст: важливо побачити на власні очі, що модуль сам поставив лапки там, де вони потрібні.

In [3]:
файл_меню = тека / "menu.csv"
стовпці = ["назва", "ціна", "категорія", "опис"]

with open(файл_меню, "w", newline="", encoding="utf-8") as файл:
    писар = csv.DictWriter(файл, fieldnames=стовпці)
    писар.writeheader()          # рядок із назвами стовпців
    писар.writerows(меню)        # усі записи одним викликом

сирий_текст = файл_меню.read_text(encoding="utf-8")
print(сирий_текст)

# лапки зʼявились самі — модуль побачив кому й кому всередині поля
assert '"молоко, кориця"' in сирий_текст, "модуль мав узяти опис у лапки!"
print("✅ поле з комою екрановано автоматично")
назва,ціна,категорія,опис
Еспресо,25,кава,міцна
Капучино,45,кава,"молоко, кориця"
Латте,50,кава,"сироп ""Карамель"""
Чай,30,чай,травʼяний

✅ поле з комою екрановано автоматично

4 · Наївний поділ проти модуля¶

Тепер найголовніша перевірка теми. Напишемо «свою реалізацію» розбору (звичайний split(",")) і зіставимо її з бібліотечною — csv.reader.

На простому рядку обидві дають те саме. На рядку з комою всередині поля наша ламається, а бібліотечна — ні. Це і є відповідь на питання «навіщо цілий модуль заради однієї коми».

In [4]:
рядки_файлу = сирий_текст.splitlines()
простий = рядки_файлу[1]      # Еспресо: ком усередині полів немає
складний = рядки_файлу[2]     # Капучино: кома всередині опису

def наш_розбір(рядок):
    """Найпростіше, що спадає на думку: поділити по комах."""
    return рядок.split(",")

def бібліотечний_розбір(рядок):
    # csv.reader читає послідовність рядків, тому передаємо список з одного
    return next(csv.reader([рядок]))

print("простий рядок :", простий)
print("  наш         :", наш_розбір(простий))
print("  бібліотечний:", бібліотечний_розбір(простий))
print()
print("складний рядок:", складний)
print("  наш         :", наш_розбір(складний))
print("  бібліотечний:", бібліотечний_розбір(складний))

# на простому рядку наша реалізація збігається з бібліотечною
assert наш_розбір(простий) == бібліотечний_розбір(простий), "на простому рядку мали збігтися!"
# а на складному — розходиться, і саме в кількості полів
assert len(наш_розбір(складний)) == 5
assert len(бібліотечний_розбір(складний)) == 4
assert бібліотечний_розбір(складний)[3] == "молоко, кориця"
print()
print("✅ збігається там, де ком немає — і розходиться там, де вони є")
простий рядок : Еспресо,25,кава,міцна
  наш         : ['Еспресо', '25', 'кава', 'міцна']
  бібліотечний: ['Еспресо', '25', 'кава', 'міцна']

складний рядок: Капучино,45,кава,"молоко, кориця"
  наш         : ['Капучино', '45', 'кава', '"молоко', ' кориця"']
  бібліотечний: ['Капучино', '45', 'кава', 'молоко, кориця']

✅ збігається там, де ком немає — і розходиться там, де вони є

5 · Читання через DictReader¶

DictReader зʼїдає перший рядок як назви полів і далі віддає словники. Порівняємо кількість записів із кількістю рядків у файлі: у файлі їх на один більше — це шапка.

In [5]:
with open(файл_меню, newline="", encoding="utf-8") as файл:
    прочитане_меню = list(csv.DictReader(файл))

for запис in прочитане_меню:
    print(запис)

print()
print("рядків у файлі:", len(рядки_файлу), "· записів після DictReader:", len(прочитане_меню))
assert len(прочитане_меню) == len(меню), "записів має бути стільки ж, скільки ми записали"
print("✅ шапка витрачена на назви полів, дані на місці")
{'назва': 'Еспресо', 'ціна': '25', 'категорія': 'кава', 'опис': 'міцна'}
{'назва': 'Капучино', 'ціна': '45', 'категорія': 'кава', 'опис': 'молоко, кориця'}
{'назва': 'Латте', 'ціна': '50', 'категорія': 'кава', 'опис': 'сироп "Карамель"'}
{'назва': 'Чай', 'ціна': '30', 'категорія': 'чай', 'опис': 'травʼяний'}

рядків у файлі: 5 · записів після DictReader: 4
✅ шапка витрачена на назви полів, дані на місці

6 · З CSV усе приходить рядками¶

Ціна була числом, а повернулась текстом. Це не помилка модуля — у форматі просто немає типів. Перетворення робимо самі, в одному місці, одразу після читання.

In [6]:
перший = прочитане_меню[0]
print("що прочиталось :", перший["ціна"], "· тип:", type(перший["ціна"]).__name__)
print("що записували  :", меню[0]["ціна"], "· тип:", type(меню[0]["ціна"]).__name__)

def з_типами(запис):
    """Повертає копію запису, у якій ціна вже число, а не текст."""
    новий = dict(запис)               # не псуємо оригінал
    новий["ціна"] = int(запис["ціна"])
    return новий

меню_з_типами = [з_типами(запис) for запис in прочитане_меню]
сума = sum(запис["ціна"] for запис in меню_з_типами)

print()
print("після перетворення:", меню_з_типами[0]["ціна"], "· тип:",
      type(меню_з_типами[0]["ціна"]).__name__)
print("вартість усього меню:", сума, "грн")

assert меню_з_типами == меню, "після перетворення дані мають збігтися з вихідними"
print("✅ обернули запис → читання → перетворення й отримали те саме, з чого почали")
що прочиталось : 25 · тип: str
що записували  : 25 · тип: int

після перетворення: 25 · тип: int
вартість усього меню: 150 грн
✅ обернули запис → читання → перетворення й отримали те саме, з чого почали

7 · Той самий файл з іншим роздільником¶

Український Excel зберігає CSV через крапку з комою. Запишемо меню саме так — і прочитаємо двома читачами: правильно налаштованим і тим, що за замовчуванням. Другий не впаде: він просто поверне сміття.

In [7]:
файл_excel = тека / "menu_excel.csv"

with open(файл_excel, "w", newline="", encoding="utf-8") as файл:
    писар = csv.DictWriter(файл, fieldnames=стовпці, delimiter=";")
    писар.writeheader()
    писар.writerows(меню)

print(файл_excel.read_text(encoding="utf-8"))

with open(файл_excel, newline="", encoding="utf-8") as файл:
    правильно = next(csv.reader(файл, delimiter=";"))

with open(файл_excel, newline="", encoding="utf-8") as файл:
    за_замовчуванням = next(csv.reader(файл))

print("з delimiter=';' :", правильно, "→", len(правильно), "поля")
print("без delimiter   :", за_замовчуванням, "→", len(за_замовчуванням), "поле")

assert len(правильно) == 4
assert len(за_замовчуванням) == 1
print("✅ помилки не було — був тихо неправильний розбір")
назва;ціна;категорія;опис
Еспресо;25;кава;міцна
Капучино;45;кава;молоко, кориця
Латте;50;кава;"сироп ""Карамель"""
Чай;30;чай;травʼяний

з delimiter=';' : ['назва', 'ціна', 'категорія', 'опис'] → 4 поля
без delimiter   : ['назва;ціна;категорія;опис'] → 1 поле
✅ помилки не було — був тихо неправильний розбір

8 · JSON: замовлення з вкладеністю¶

У замовленні є список позицій — у таблицю таке не лягає. Зверни увагу на поле теги: ми навмисно кладемо туди кортеж. Через дві клітинки перевіримо, чим він повернеться.

In [8]:
замовлення = {
    "номер": 17,
    "клієнт": "Аня",
    "оплачено": True,
    "теги": ("самовиніс", "знижка"),          # саме кортеж, не список
    "позиції": [
        {"назва": "Капучино", "кількість": 2},
        {"назва": "Чай", "кількість": 1},
    ],
}

файл_замовлення = тека / "order.json"
with open(файл_замовлення, "w", encoding="utf-8") as файл:
    json.dump(замовлення, файл, ensure_ascii=False, indent=2)

print(файл_замовлення.read_text(encoding="utf-8"))
{
  "номер": 17,
  "клієнт": "Аня",
  "оплачено": true,
  "теги": [
    "самовиніс",
    "знижка"
  ],
  "позиції": [
    {
      "назва": "Капучино",
      "кількість": 2
    },
    {
      "назва": "Чай",
      "кількість": 1
    }
  ]
}

9 · ensure_ascii: та сама інформація, різний файл¶

Порівняємо два записи одного словника. Обидва — коректний JSON, обидва читаються назад в один і той самий обʼєкт. Різниця лише в тому, чи зможе прочитати файл людина.

In [9]:
з_екрануванням = json.dumps(замовлення, ensure_ascii=True)   # так за замовчуванням
читабельно = json.dumps(замовлення, ensure_ascii=False)      # так треба писати

print("ensure_ascii=True :", з_екрануванням[:90], "...")
print()
print("ensure_ascii=False:", читабельно[:90], "...")
print()
print("байтів у файлі:", len(з_екрануванням.encode("utf-8")),
      "проти", len(читабельно.encode("utf-8")))

assert "\\u04" in з_екрануванням, "українські літери мали перетворитись на \\uXXXX"
assert "Аня" in читабельно
# найголовніше: дані від прапорця не залежать
assert json.loads(з_екрануванням) == json.loads(читабельно)
print("✅ вміст однаковий, читабельність — ні")
ensure_ascii=True : {"\u043d\u043e\u043c\u0435\u0440": 17, "\u043a\u043b\u0456\u0454\u043d\u0442": "\u0410\u04 ...

ensure_ascii=False: {"номер": 17, "клієнт": "Аня", "оплачено": true, "теги": ["самовиніс", "знижка"], "позиції ...

байтів у файлі: 602 проти 254
✅ вміст однаковий, читабельність — ні

10 · Кортеж поїхав у файл списком¶

Головна перевірка теми. Кортеж записався як масив JSON — а масив читається назад тільки списком. Тип змінився мовчки, без жодного попередження.

In [10]:
with open(файл_замовлення, encoding="utf-8") as файл:
    прочитане_замовлення = json.load(файл)

print("було  :", замовлення["теги"], "· тип:", type(замовлення["теги"]).__name__)
print("стало :", прочитане_замовлення["теги"], "· тип:",
      type(прочитане_замовлення["теги"]).__name__)

assert isinstance(замовлення["теги"], tuple), "у памʼяті це був кортеж"
assert isinstance(прочитане_замовлення["теги"], list), "після JSON це вже список!"
assert list(замовлення["теги"]) == прочитане_замовлення["теги"], "вміст не змінився"
assert замовлення != прочитане_замовлення, "словники більше не рівні — через тип тегів"
print()
print("✅ вміст той самий, тип інший — і саме тому словники вже не рівні")
було  : ('самовиніс', 'знижка') · тип: tuple
стало : ['самовиніс', 'знижка'] · тип: list

✅ вміст той самий, тип інший — і саме тому словники вже не рівні

11 · Чого JSON не вміє взагалі¶

Множина й дата не серіалізуються: Python не вгадує, у що їх перетворити, і чесно кидає TypeError. Ловимо його й перетворюємо самі.

In [11]:
з_множиною = {"теги": {"кава", "знижка"}}

try:
    json.dumps(з_множиною)
except TypeError as помилка:
    print("не записалось:", помилка)

# перетворення руками: множина → відсортований список (щоб результат був відтворюваним)
виправлене = {"теги": sorted(з_множиною["теги"])}
текст = json.dumps(виправлене, ensure_ascii=False)
print("після перетворення:", текст)

assert json.loads(текст)["теги"] == ["знижка", "кава"]
print("✅ множину довелося звести до списку — самотужки")
не записалось: Object of type set is not JSON serializable
після перетворення: {"теги": ["знижка", "кава"]}
✅ множину довелося звести до списку — самотужки

12 · Обірваний JSON¶

Файли ззовні бувають биті. JSONDecodeError каже, де саме зламалось, — і це підвид ValueError, тому ловити можна будь-який із двох.

In [12]:
обірваний = '{"номер": 17, "клієнт": "Аня"'   # закривальної дужки немає

сталася = None

try:
    json.loads(обірваний)
except json.JSONDecodeError as помилка:
    print("причина :", помилка.msg)
    print("місце   : рядок", помилка.lineno, "стовпець", помилка.colno,
          "(символ", str(помилка.pos) + ")")
    сталася = помилка   # змінна з except зникає одразу за блоком — зберігаємо
    дані = {}           # працюємо далі з порожнечею, а не падаємо

print("програма живе далі, дані =", дані)
assert isinstance(сталася, ValueError), "JSONDecodeError — це підвид ValueError"
print("✅ поломка оброблена, а не проігнорована")
причина : Expecting ',' delimiter
місце   : рядок 1 стовпець 30 (символ 29)
програма живе далі, дані = {}
✅ поломка оброблена, а не проігнорована

13 · CSV із пропущеним стовпцем¶

А тут виняток не кинеться взагалі. DictReader мовчки поставить None у відсутнє поле, і крива стрічка поїде далі по програмі. Перевірку доводиться писати самому.

In [13]:
файл_кривий = тека / "broken.csv"
файл_кривий.write_text(
    "назва,ціна,категорія,опис\n"
    "Ромашка,35,чай,мʼякий\n"
    "Матча,60\n"                       # бракує двох останніх полів
    "Какао,40,какао,густе,новинка\n",  # а тут одне зайве
    encoding="utf-8",
)

with open(файл_кривий, newline="", encoding="utf-8") as файл:
    криві_записи = list(csv.DictReader(файл))

for запис in криві_записи:
    print(запис)

def проблеми_рядка(запис):
    """Повертає список претензій до запису — порожній, якщо все гаразд."""
    знайдені = []
    for поле, значення in запис.items():
        if поле is None:
            знайдені.append(f"зайві поля: {значення}")
        elif значення is None:
            знайдені.append(f"бракує поля «{поле}»")
    return знайдені

print()
for номер, запис in enumerate(криві_записи, start=1):
    претензії = проблеми_рядка(запис)
    стан = "ok" if not претензії else "; ".join(претензії)
    print(f"рядок {номер}: {стан}")

assert проблеми_рядка(криві_записи[0]) == []
assert проблеми_рядка(криві_записи[1]) == ["бракує поля «категорія»", "бракує поля «опис»"]
assert проблеми_рядка(криві_записи[2]) == ["зайві поля: ['новинка']"]
print()
print("✅ модуль промовчав — перевірку зробили ми")
{'назва': 'Ромашка', 'ціна': '35', 'категорія': 'чай', 'опис': 'мʼякий'}
{'назва': 'Матча', 'ціна': '60', 'категорія': None, 'опис': None}
{'назва': 'Какао', 'ціна': '40', 'категорія': 'какао', 'опис': 'густе', None: ['новинка']}

рядок 1: ok
рядок 2: бракує поля «категорія»; бракує поля «опис»
рядок 3: зайві поля: ['новинка']

✅ модуль промовчав — перевірку зробили ми

14 · Який формат дешевший¶

Порівняємо на однакових даних: 500 плоских записів про виміри температури. Форма записів однакова, вкладеності немає — класичний випадок для CSV. Розмір беремо не на око, а з файлової системи.

In [14]:
виміри = [
    {"час": f"2026-03-01 {хвилина // 60:02d}:{хвилина % 60:02d}",
     "температура": 18 + хвилина % 7,
     "кімната": "зал"}
    for хвилина in range(500)
]

файл_csv = тека / "temps.csv"
with open(файл_csv, "w", newline="", encoding="utf-8") as файл:
    писар = csv.DictWriter(файл, fieldnames=["час", "температура", "кімната"])
    писар.writeheader()
    писар.writerows(виміри)

файл_json = тека / "temps.json"
with open(файл_json, "w", encoding="utf-8") as файл:
    json.dump(виміри, файл, ensure_ascii=False)

розмір_csv = файл_csv.stat().st_size
розмір_json = файл_json.stat().st_size

print("записів   :", len(виміри))
print("CSV, байт :", розмір_csv)
print("JSON, байт:", розмір_json)
print(f"JSON важчий у {розмір_json / розмір_csv:.1f} раза — ключі повторюються в кожному записі")

assert розмір_csv < розмір_json, "на плоских даних CSV має бути компактнішим"
print("✅ форма даних плоска — CSV тут і дешевший, і природніший")
записів   : 500
CSV, байт : 14046
JSON, байт: 45000
JSON важчий у 3.2 раза — ключі повторюються в кожному записі
✅ форма даних плоска — CSV тут і дешевший, і природніший

15 · Прибираємо за собою¶

shutil.rmtree видаляє теку з усім вмістом. Тимчасові файли не мають переживати запуск зошита — інакше через місяць у системі буде сотня «kavyarnia-…».

In [15]:
файли = sorted(шлях.name for шлях in тека.iterdir())
print("видаляємо", len(файли), "файлів:", файли)

shutil.rmtree(тека)

print("тека існує:", тека.exists())
assert not тека.exists(), "тимчасова тека мала зникнути"
print("✅ прибрано")
видаляємо 6 файлів: ['broken.csv', 'menu.csv', 'menu_excel.csv', 'order.json', 'temps.csv', 'temps.json']
тека існує: False
✅ прибрано

Завдання¶

🟢 Рівень 1 — База¶

Створи свій CSV на 5 рядків (наприклад, список книжок: назва, автор, рік, нотатка) так, щоб хоча б в одному полі була кома. Запиши його через csv.DictWriter, прочитай через csv.DictReader і переконайся assert-ом, що поле з комою повернулось цілим.

Зроблено, якщо: зошит виконується без помилок, а assert на цілісність поля з комою проходить.

🟡 Рівень 2 — Плюс¶

Перетвори те саме на JSON — але так, щоб кожна книжка мала список жанрів (вкладеність, якої в CSV не було). Запиши з ensure_ascii=False, indent=2, прочитай назад і порівняй розмір обох файлів через .stat().st_size. Поясни в комірці-markdown, який формат ти б обрав для цих даних і чому.

Зроблено, якщо: обидва файли створено, надруковано їхні розміри й написано висновок із двох речень, який спирається на форму даних, а не лише на розмір.

🔴 Рівень 3 — Виклик¶

Напиши функцію перевір_файл(шлях, стовпці), яка читає CSV і повертає список проблем: рядки з пропущеними полями, рядки із зайвими полями й рядки, у яких ціна не перетворюється на число. Перевір її на файлі, який навмисно зіпсуєш трьома різними способами.

Зроблено, якщо: функція знаходить усі три типи проблем, повертає номер рядка для кожної, а на коректному файлі повертає порожній список.

Підказки¶

  • Порожня клітинка — це "", а відсутнє поле — це None. Це різні випадки, і перевіряти їх треба окремо.
  • Щоб зловити «ціна не число», не пиши if: спробуй int(...) у try і злови ValueError.
  • Номер рядка зручно брати з enumerate(читач, start=2) — двійка тому, що перший рядок файлу забрала шапка.