Практика · CSV і JSON¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що в лекції, — маленька кавʼярня. Меню поїде в CSV (бо це таблиця), замовлення — у JSON (бо в ньому вкладений список позицій).
Що зробимо:
- створимо тимчасову теку й покладемо в неї всі файли;
- запишемо меню в CSV через
DictWriterі прочитаємо назад черезDictReader; - переконаємось, що
split(",")ламає рядок, а модульcsv— ні; - перетворимо типи: з CSV усе приходить рядками;
- запишемо замовлення в JSON з
ensure_ascii=Falseі побачимо, що кортеж став списком; - зловимо дві поломки: обірваний JSON і CSV із пропущеним стовпцем;
- порівняємо розмір CSV і JSON на однакових даних;
- приберемо за собою.
Мережа не потрібна: усі дані ми вигадуємо самі, усі файли живуть у тимчасовій теці, яку наприкінці видаляємо.
1 · Тимчасова тека¶
tempfile.mkdtemp() створює порожню теку в системному місці для тимчасових файлів
і повертає шлях до неї. Так ми нічого не насмітимо в робочій теці — і в кінці
приберемо все одним рухом.
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 · Дані меню¶
Звичайний список словників — те, як дані зазвичай і живуть у програмі. Зверни увагу на опис капучино: у ньому є кома. Саме вона зробить усю подальшу роботу цікавою.
меню = [
{"назва": "Еспресо", "ціна": 25, "категорія": "кава", "опис": "міцна"},
{"назва": "Капучино", "ціна": 45, "категорія": "кава", "опис": "молоко, кориця"},
{"назва": "Латте", "ціна": 50, "категорія": "кава", "опис": 'сироп "Карамель"'},
{"назва": "Чай", "ціна": 30, "категорія": "чай", "опис": "травʼяний"},
]
print("напоїв у меню:", len(меню))
for напій in меню:
print(" ", напій["назва"], "—", напій["ціна"], "грн ·", напій["опис"])
напоїв у меню: 4 Еспресо — 25 грн · міцна Капучино — 45 грн · молоко, кориця Латте — 50 грн · сироп "Карамель" Чай — 30 грн · травʼяний
3 · Запис у CSV через DictWriter¶
Три речі, які тут обовʼязкові й пояснені в лекції:
newline=""— щоб модуль сам вирішував, чим завершувати рядок;encoding="utf-8"— щоб кирилиця не залежала від налаштувань системи;fieldnames— порядок стовпців задаємо ми, а не випадок.
Одразу після запису надрукуємо файл як текст: важливо побачити на власні очі, що модуль сам поставив лапки там, де вони потрібні.
файл_меню = тека / "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.
На простому рядку обидві дають те саме. На рядку з комою всередині поля наша ламається, а бібліотечна — ні. Це і є відповідь на питання «навіщо цілий модуль заради однієї коми».
рядки_файлу = сирий_текст.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 зʼїдає перший рядок як назви полів і далі віддає словники.
Порівняємо кількість записів із кількістю рядків у файлі: у файлі їх на один
більше — це шапка.
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 усе приходить рядками¶
Ціна була числом, а повернулась текстом. Це не помилка модуля — у форматі просто немає типів. Перетворення робимо самі, в одному місці, одразу після читання.
перший = прочитане_меню[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 через крапку з комою. Запишемо меню саме так — і прочитаємо двома читачами: правильно налаштованим і тим, що за замовчуванням. Другий не впаде: він просто поверне сміття.
файл_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: замовлення з вкладеністю¶
У замовленні є список позицій — у таблицю таке не лягає. Зверни увагу на поле
теги: ми навмисно кладемо туди кортеж. Через дві клітинки перевіримо,
чим він повернеться.
замовлення = {
"номер": 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, обидва читаються назад в один і той самий обʼєкт. Різниця лише в тому, чи зможе прочитати файл людина.
з_екрануванням = 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 — а масив читається назад тільки списком. Тип змінився мовчки, без жодного попередження.
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. Ловимо його й перетворюємо самі.
з_множиною = {"теги": {"кава", "знижка"}}
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, тому ловити можна будь-який із двох.
обірваний = '{"номер": 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
у відсутнє поле, і крива стрічка поїде далі по програмі. Перевірку доводиться
писати самому.
файл_кривий = тека / "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. Розмір беремо не на око, а з файлової системи.
виміри = [
{"час": 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-…».
файли = 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)— двійка тому, що перший рядок файлу забрала шапка.